<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
	<id>https://knockturnbound.net/lexicon/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Wylder+Merrow</id>
	<title>KB Lexicon - User contributions [en]</title>
	<link rel="self" type="application/atom+xml" href="https://knockturnbound.net/lexicon/api.php?action=feedcontributions&amp;feedformat=atom&amp;user=Wylder+Merrow"/>
	<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php/Special:Contributions/Wylder_Merrow"/>
	<updated>2026-04-17T20:39:09Z</updated>
	<subtitle>User contributions</subtitle>
	<generator>MediaWiki 1.39.13</generator>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1976</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1976"/>
		<updated>2026-04-15T22:03:24Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 72px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 72px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-grouplabel {&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1975</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1975"/>
		<updated>2026-04-15T22:02:41Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {},&lt;br /&gt;
				relationshipLabels = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
&lt;br /&gt;
		if anchorKind == 'self' then&lt;br /&gt;
			local badge = relationshipBadge(link.relationshipType)&lt;br /&gt;
			if isRealValue(badge) then&lt;br /&gt;
				addUnique(groupsByKey[key].relationshipLabels, badge)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		local group = groupsByKey[key]&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) then&lt;br /&gt;
			local union = findUnionBetween(people, personName, group.partnerName)&lt;br /&gt;
			if union then&lt;br /&gt;
				group.label = formatUnionMeta(&lt;br /&gt;
					union.unionType,&lt;br /&gt;
					union.status,&lt;br /&gt;
					union.marriageDate or union.startDate or union.engagementDate&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			if #group.relationshipLabels == 1 then&lt;br /&gt;
				group.label = group.relationshipLabels[1]&lt;br /&gt;
			elseif #group.relationshipLabels &amp;gt; 1 then&lt;br /&gt;
				group.label = table.concat(group.relationshipLabels, ' / ')&lt;br /&gt;
			else&lt;br /&gt;
				group.label = nil&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			label = rawGroup.label,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local selfRowHeight = 56&lt;br /&gt;
	local unionY = 28&lt;br /&gt;
	local stemHeight = selfRowHeight - unionY&lt;br /&gt;
	local labelY = 0&lt;br /&gt;
	local barTop = 18&lt;br /&gt;
	local branchHeight = 28&lt;br /&gt;
	local childDropHeight = branchHeight - barTop&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('height', tostring(selfRowHeight) .. 'px')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	-- partner line segments&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', tostring(unionY) .. 'px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- source stems from root/union line down to branch layer&lt;br /&gt;
	do&lt;br /&gt;
		local seenCenters = {}&lt;br /&gt;
		for _, group in ipairs(childGroups) do&lt;br /&gt;
			local center = group.sourceCenter&lt;br /&gt;
			if center and not seenCenters[center] then&lt;br /&gt;
				seenCenters[center] = true&lt;br /&gt;
&lt;br /&gt;
				local stem = selfRow:tag('div')&lt;br /&gt;
				stem:addClass('kbft-ft-sourcestem')&lt;br /&gt;
				stem:css('position', 'absolute')&lt;br /&gt;
				stem:css('top', tostring(unionY) .. 'px')&lt;br /&gt;
				stem:css('left', tostring(center) .. 'px')&lt;br /&gt;
				stem:css('width', '2px')&lt;br /&gt;
				stem:css('height', tostring(stemHeight) .. 'px')&lt;br /&gt;
				stem:css('margin-left', '-1px')&lt;br /&gt;
				stem:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- root card&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- partner cards&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('position', 'relative')&lt;br /&gt;
		branch:css('height', tostring(branchHeight) .. 'px')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
		branch:css('margin-top', '0')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('top', tostring(-childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('height', tostring(childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = selfLeft + group.sourceCenter,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs,&lt;br /&gt;
				label = group.label&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			local lineLeft, lineWidth&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
				lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
			else&lt;br /&gt;
				lineLeft = firstAnchor&lt;br /&gt;
				lineWidth = lastAnchor - firstAnchor&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('position', 'absolute')&lt;br /&gt;
			parentDrop:css('top', '0')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
			parentDrop:css('width', '2px')&lt;br /&gt;
			parentDrop:css('height', tostring(barTop) .. 'px')&lt;br /&gt;
			parentDrop:css('margin-left', '-1px')&lt;br /&gt;
			parentDrop:css('background', '#bca88e')&lt;br /&gt;
&lt;br /&gt;
			if lineWidth &amp;gt; 0 then&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('position', 'absolute')&lt;br /&gt;
				bar:css('top', tostring(barTop) .. 'px')&lt;br /&gt;
				bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
				bar:css('height', '2px')&lt;br /&gt;
				bar:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(info.label) then&lt;br /&gt;
				local labelLeft&lt;br /&gt;
				local labelWidth&lt;br /&gt;
&lt;br /&gt;
				if lineWidth &amp;gt; 0 then&lt;br /&gt;
					labelLeft = lineLeft&lt;br /&gt;
					labelWidth = lineWidth&lt;br /&gt;
				else&lt;br /&gt;
					labelWidth = 90&lt;br /&gt;
					labelLeft = sourceAnchorAbs - math.floor(labelWidth / 2)&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local labelNode = branch:tag('div')&lt;br /&gt;
				labelNode:addClass('kbft-ft-grouplabel')&lt;br /&gt;
				labelNode:css('position', 'absolute')&lt;br /&gt;
				labelNode:css('top', tostring(labelY) .. 'px')&lt;br /&gt;
				labelNode:css('left', tostring(labelLeft) .. 'px')&lt;br /&gt;
				labelNode:css('width', tostring(labelWidth) .. 'px')&lt;br /&gt;
				labelNode:css('text-align', 'center')&lt;br /&gt;
				labelNode:css('font-size', '0.8em')&lt;br /&gt;
				labelNode:css('line-height', '1')&lt;br /&gt;
				labelNode:css('color', '#5f4b36')&lt;br /&gt;
				labelNode:wikitext(info.label)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1974</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1974"/>
		<updated>2026-04-15T21:34:00Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 72px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 72px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1973</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1973"/>
		<updated>2026-04-15T21:33:13Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('height', '72px')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	-- partner line segments&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- root card&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- partner cards&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = selfLeft + group.sourceCenter,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1972</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1972"/>
		<updated>2026-04-15T21:20:26Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 72px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
  z-index: 2;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1971</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1971"/>
		<updated>2026-04-15T21:12:44Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	-- partner line segments&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- root card&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- partner cards&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = selfLeft + group.sourceCenter,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1970</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1970"/>
		<updated>2026-04-15T20:57:21Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	if #links == 0 then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerSeen = {}&lt;br /&gt;
	local partnerList = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			if not partnerSeen[link.otherParent] then&lt;br /&gt;
				partnerSeen[link.otherParent] = true&lt;br /&gt;
				table.insert(partnerList, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #partnerList == 1 then&lt;br /&gt;
		return partnerList[1]&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, partnerName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(partnerName) and isRealValue(link.otherParent) and link.otherParent == partnerName then&lt;br /&gt;
			key = 'union::' .. partnerName&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, partnerName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for gi, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local nodeWidth = SLOT_WIDTH&lt;br /&gt;
	if groupsRowWidth &amp;gt; nodeWidth then&lt;br /&gt;
		nodeWidth = groupsRowWidth&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)&lt;br /&gt;
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER&lt;br /&gt;
	local unionAnchorAbs = selfLeft + UNION_CENTER&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfSlot = selfRow:tag('div')&lt;br /&gt;
	selfSlot:addClass('kbft-ft-selfslot')&lt;br /&gt;
&lt;br /&gt;
	local anchorWrap = selfSlot:tag('div')&lt;br /&gt;
	anchorWrap:addClass('kbft-ft-anchor')&lt;br /&gt;
	if focus then&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
	else&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local unionLine = selfSlot:tag('div')&lt;br /&gt;
	unionLine:addClass('kbft-ft-unionline')&lt;br /&gt;
	if not isRealValue(partnerName) then&lt;br /&gt;
		unionLine:addClass('kbft-ft-hidden')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerWrap = selfSlot:tag('div')&lt;br /&gt;
	partnerWrap:addClass('kbft-ft-partner')&lt;br /&gt;
	if isRealValue(partnerName) then&lt;br /&gt;
		partnerWrap:node(renderCard(people, partnerName))&lt;br /&gt;
	else&lt;br /&gt;
		partnerWrap:addClass('kbft-ft-partner-empty')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = (group.anchorKind == 'union') and unionAnchorAbs or selfAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = selfAnchorAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1969</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1969"/>
		<updated>2026-04-15T20:52:21Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1968</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1968"/>
		<updated>2026-04-15T20:48:38Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Shared children can drop from union;&lt;br /&gt;
   solo/adoptive children can drop from the child only.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1967</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1967"/>
		<updated>2026-04-15T20:44:54Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1966</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1966"/>
		<updated>2026-04-15T20:42:43Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	if #links == 0 then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerSeen = {}&lt;br /&gt;
	local partnerList = {}&lt;br /&gt;
	local hasSolo = false&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			if not partnerSeen[link.otherParent] then&lt;br /&gt;
				partnerSeen[link.otherParent] = true&lt;br /&gt;
				table.insert(partnerList, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			hasSolo = true&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if hasSolo then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #partnerList == 1 then&lt;br /&gt;
		return partnerList[1]&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local childNames = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childNodes = {}&lt;br /&gt;
	local childRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for i, childName in ipairs(childNames) do&lt;br /&gt;
		local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
		table.insert(childNodes, childNode)&lt;br /&gt;
		childRowWidth = childRowWidth + childNode.width&lt;br /&gt;
		if i &amp;gt; 1 then&lt;br /&gt;
			childRowWidth = childRowWidth + CHILD_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local nodeWidth = SLOT_WIDTH&lt;br /&gt;
	if childRowWidth &amp;gt; nodeWidth then&lt;br /&gt;
		nodeWidth = childRowWidth&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)&lt;br /&gt;
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfSlot = selfRow:tag('div')&lt;br /&gt;
	selfSlot:addClass('kbft-ft-selfslot')&lt;br /&gt;
&lt;br /&gt;
	local anchorWrap = selfSlot:tag('div')&lt;br /&gt;
	anchorWrap:addClass('kbft-ft-anchor')&lt;br /&gt;
	if focus then&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
	else&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local unionLine = selfSlot:tag('div')&lt;br /&gt;
	unionLine:addClass('kbft-ft-unionline')&lt;br /&gt;
	if not isRealValue(partnerName) then&lt;br /&gt;
		unionLine:addClass('kbft-ft-hidden')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerWrap = selfSlot:tag('div')&lt;br /&gt;
	partnerWrap:addClass('kbft-ft-partner')&lt;br /&gt;
	if isRealValue(partnerName) then&lt;br /&gt;
		partnerWrap:node(renderCard(people, partnerName))&lt;br /&gt;
	else&lt;br /&gt;
		partnerWrap:addClass('kbft-ft-partner-empty')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childNodes &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(childRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - childRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childAbsAnchors = {}&lt;br /&gt;
		local runningX = 0&lt;br /&gt;
&lt;br /&gt;
		for i, childNode in ipairs(childNodes) do&lt;br /&gt;
			local childWrap = childRow:tag('div')&lt;br /&gt;
			childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
			childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
			if i &amp;lt; #childNodes then&lt;br /&gt;
				childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local drop = childWrap:tag('div')&lt;br /&gt;
			drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
			drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
			table.insert(childAbsAnchors, math.floor((nodeWidth - childRowWidth) / 2) + runningX + childNode.anchorX)&lt;br /&gt;
			runningX = runningX + childNode.width + (i &amp;lt; #childNodes and CHILD_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local parentDrop = branch:tag('div')&lt;br /&gt;
		parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
		parentDrop:css('left', tostring(selfAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
		local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
		if #childAbsAnchors == 1 then&lt;br /&gt;
			local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
&lt;br /&gt;
			if selfAnchorAbs ~= onlyAnchor then&lt;br /&gt;
				local lineLeft = math.min(selfAnchorAbs, onlyAnchor)&lt;br /&gt;
				local lineWidth = math.abs(selfAnchorAbs - onlyAnchor)&lt;br /&gt;
				if lineWidth &amp;gt; 0 then&lt;br /&gt;
					local bar = branch:tag('div')&lt;br /&gt;
					bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
					bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
					bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			local bar = branch:tag('div')&lt;br /&gt;
			bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
			bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
			bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = selfAnchorAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1965</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1965"/>
		<updated>2026-04-15T20:38:45Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Keep this minimal; critical positioning is inline in Lua.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-grouplabel {&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1964</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1964"/>
		<updated>2026-04-15T20:38:11Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {},&lt;br /&gt;
				links = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
		table.insert(groupsByKey[key].links, link)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0,&lt;br /&gt;
			label = nil&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
&lt;br /&gt;
			local union = findUnionBetween(people, personName, group.partnerName)&lt;br /&gt;
			if union then&lt;br /&gt;
				group.label = formatUnionMeta(&lt;br /&gt;
					union.unionType,&lt;br /&gt;
					union.status,&lt;br /&gt;
					union.marriageDate or union.startDate or union.engagementDate&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
			-- label self-only groups when they are adoptive/step/etc.&lt;br /&gt;
			local relKinds = {}&lt;br /&gt;
			local seenKinds = {}&lt;br /&gt;
&lt;br /&gt;
			for _, link in ipairs(rawGroup.links or {}) do&lt;br /&gt;
				local badge = relationshipBadge(link.relationshipType)&lt;br /&gt;
				if isRealValue(badge) and not seenKinds[badge] then&lt;br /&gt;
					seenKinds[badge] = true&lt;br /&gt;
					table.insert(relKinds, badge)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if #relKinds == 1 then&lt;br /&gt;
				group.label = relKinds[1]&lt;br /&gt;
			elseif #relKinds &amp;gt; 1 then&lt;br /&gt;
				group.label = table.concat(relKinds, ' / ')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('position', 'relative')&lt;br /&gt;
	node:css('display', 'inline-block')&lt;br /&gt;
	node:css('vertical-align', 'top')&lt;br /&gt;
	node:css('text-align', 'left')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('height', '72px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-rootslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-partnerslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local baseBarTop = 28&lt;br /&gt;
		local levelStep = 26&lt;br /&gt;
		local labelGap = 14&lt;br /&gt;
&lt;br /&gt;
		for i, group in ipairs(childGroups) do&lt;br /&gt;
			group.barTop = baseBarTop + ((i - 1) * levelStep)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local maxBarTop = childGroups[#childGroups].barTop&lt;br /&gt;
		local branchHeight = maxBarTop + 14&lt;br /&gt;
&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('position', 'relative')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
		branch:css('height', tostring(branchHeight) .. 'px')&lt;br /&gt;
		branch:css('margin-top', '4px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('display', 'flex')&lt;br /&gt;
		childRow:css('align-items', 'flex-start')&lt;br /&gt;
		childRow:css('justify-content', 'flex-start')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
			local childDropHeight = branchHeight - group.barTop&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('position', 'relative')&lt;br /&gt;
				childWrap:css('display', 'inline-block')&lt;br /&gt;
				childWrap:css('vertical-align', 'top')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('position', 'absolute')&lt;br /&gt;
				drop:css('top', tostring(-childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
				drop:css('width', '2px')&lt;br /&gt;
				drop:css('height', tostring(childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('margin-left', '-1px')&lt;br /&gt;
				drop:css('background', '#bca88e')&lt;br /&gt;
				drop:css('z-index', '1')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local sourceAnchorAbs = selfLeft + group.sourceCenter&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = sourceAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs,&lt;br /&gt;
				barTop = group.barTop,&lt;br /&gt;
				label = group.label&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
			local barTop = info.barTop&lt;br /&gt;
			local label = info.label&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('position', 'absolute')&lt;br /&gt;
			parentDrop:css('top', '0')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
			parentDrop:css('width', '2px')&lt;br /&gt;
			parentDrop:css('height', tostring(barTop) .. 'px')&lt;br /&gt;
			parentDrop:css('margin-left', '-1px')&lt;br /&gt;
			parentDrop:css('background', '#bca88e')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
			local lineLeft, lineWidth&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
				lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
			else&lt;br /&gt;
				lineLeft = firstAnchor&lt;br /&gt;
				lineWidth = lastAnchor - firstAnchor&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if lineWidth and lineWidth &amp;gt; 0 then&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('position', 'absolute')&lt;br /&gt;
				bar:css('top', tostring(barTop) .. 'px')&lt;br /&gt;
				bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
				bar:css('height', '2px')&lt;br /&gt;
				bar:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(label) then&lt;br /&gt;
				local labelLeft = math.min(sourceAnchorAbs, firstAnchor)&lt;br /&gt;
				local labelRight = math.max(sourceAnchorAbs, lastAnchor)&lt;br /&gt;
				local labelWidth = labelRight - labelLeft&lt;br /&gt;
&lt;br /&gt;
				if labelWidth &amp;lt; 90 then&lt;br /&gt;
					labelWidth = 90&lt;br /&gt;
					labelLeft = math.floor(((math.min(sourceAnchorAbs, firstAnchor) + math.max(sourceAnchorAbs, lastAnchor)) / 2) - (labelWidth / 2))&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local labelNode = branch:tag('div')&lt;br /&gt;
				labelNode:addClass('kbft-ft-grouplabel')&lt;br /&gt;
				labelNode:css('position', 'absolute')&lt;br /&gt;
				labelNode:css('top', tostring(barTop - labelGap - 12) .. 'px')&lt;br /&gt;
				labelNode:css('left', tostring(labelLeft) .. 'px')&lt;br /&gt;
				labelNode:css('width', tostring(labelWidth) .. 'px')&lt;br /&gt;
				labelNode:css('text-align', 'center')&lt;br /&gt;
				labelNode:css('font-size', '0.8em')&lt;br /&gt;
				labelNode:css('color', '#5f4b36')&lt;br /&gt;
				labelNode:wikitext(label)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1963</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1963"/>
		<updated>2026-04-15T20:33:02Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('position', 'relative')&lt;br /&gt;
	node:css('display', 'inline-block')&lt;br /&gt;
	node:css('vertical-align', 'top')&lt;br /&gt;
	node:css('text-align', 'left')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('height', '72px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-rootslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-partnerslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local baseBarTop = 14&lt;br /&gt;
		local levelStep = 8&lt;br /&gt;
&lt;br /&gt;
		for i, group in ipairs(childGroups) do&lt;br /&gt;
			group.barTop = baseBarTop + ((i - 1) * levelStep)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local maxBarTop = childGroups[#childGroups].barTop&lt;br /&gt;
		local branchHeight = maxBarTop + 10&lt;br /&gt;
&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('position', 'relative')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
		branch:css('height', tostring(branchHeight) .. 'px')&lt;br /&gt;
		branch:css('margin-top', '4px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('display', 'flex')&lt;br /&gt;
		childRow:css('align-items', 'flex-start')&lt;br /&gt;
		childRow:css('justify-content', 'flex-start')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
			local childDropHeight = branchHeight - group.barTop&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('position', 'relative')&lt;br /&gt;
				childWrap:css('display', 'inline-block')&lt;br /&gt;
				childWrap:css('vertical-align', 'top')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('position', 'absolute')&lt;br /&gt;
				drop:css('top', tostring(-childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
				drop:css('width', '2px')&lt;br /&gt;
				drop:css('height', tostring(childDropHeight) .. 'px')&lt;br /&gt;
				drop:css('margin-left', '-1px')&lt;br /&gt;
				drop:css('background', '#bca88e')&lt;br /&gt;
				drop:css('z-index', '1')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local sourceAnchorAbs = selfLeft + group.sourceCenter&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = sourceAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs,&lt;br /&gt;
				barTop = group.barTop&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
			local barTop = info.barTop&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('position', 'absolute')&lt;br /&gt;
			parentDrop:css('top', '0')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
			parentDrop:css('width', '2px')&lt;br /&gt;
			parentDrop:css('height', tostring(barTop) .. 'px')&lt;br /&gt;
			parentDrop:css('margin-left', '-1px')&lt;br /&gt;
			parentDrop:css('background', '#bca88e')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('position', 'absolute')&lt;br /&gt;
						bar:css('top', tostring(barTop) .. 'px')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
						bar:css('height', '2px')&lt;br /&gt;
						bar:css('background', '#bca88e')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('position', 'absolute')&lt;br /&gt;
				bar:css('top', tostring(barTop) .. 'px')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('height', '2px')&lt;br /&gt;
				bar:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1962</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1962"/>
		<updated>2026-04-15T20:24:18Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
			group.sourceCenter = layout.unionCenters[group.partnerName]&lt;br /&gt;
		else&lt;br /&gt;
			group.sourceCenter = layout.rootCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- THIS is the important fix:&lt;br /&gt;
	-- sort child groups left-to-right by the source anchor they belong to&lt;br /&gt;
	table.sort(childGroups, function(a, b)&lt;br /&gt;
		if a.sourceCenter ~= b.sourceCenter then&lt;br /&gt;
			return a.sourceCenter &amp;lt; b.sourceCenter&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local an = (a.partnerName or '')&lt;br /&gt;
		local bn = (b.partnerName or '')&lt;br /&gt;
		return mw.ustring.lower(an) &amp;lt; mw.ustring.lower(bn)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
	for gi, group in ipairs(childGroups) do&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('position', 'relative')&lt;br /&gt;
	node:css('display', 'inline-block')&lt;br /&gt;
	node:css('vertical-align', 'top')&lt;br /&gt;
	node:css('text-align', 'left')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('height', '72px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-rootslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-partnerslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('position', 'relative')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
		branch:css('height', '24px')&lt;br /&gt;
		branch:css('margin-top', '4px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('display', 'flex')&lt;br /&gt;
		childRow:css('align-items', 'flex-start')&lt;br /&gt;
		childRow:css('justify-content', 'flex-start')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('position', 'relative')&lt;br /&gt;
				childWrap:css('display', 'inline-block')&lt;br /&gt;
				childWrap:css('vertical-align', 'top')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('position', 'absolute')&lt;br /&gt;
				drop:css('top', '-10px')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
				drop:css('width', '2px')&lt;br /&gt;
				drop:css('height', '10px')&lt;br /&gt;
				drop:css('margin-left', '-1px')&lt;br /&gt;
				drop:css('background', '#bca88e')&lt;br /&gt;
				drop:css('z-index', '1')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local sourceAnchorAbs = selfLeft + group.sourceCenter&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = sourceAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('position', 'absolute')&lt;br /&gt;
			parentDrop:css('top', '0')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
			parentDrop:css('width', '2px')&lt;br /&gt;
			parentDrop:css('height', '14px')&lt;br /&gt;
			parentDrop:css('margin-left', '-1px')&lt;br /&gt;
			parentDrop:css('background', '#bca88e')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('position', 'absolute')&lt;br /&gt;
						bar:css('top', '14px')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
						bar:css('height', '2px')&lt;br /&gt;
						bar:css('background', '#bca88e')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('position', 'absolute')&lt;br /&gt;
				bar:css('top', '14px')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('height', '2px')&lt;br /&gt;
				bar:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1961</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1961"/>
		<updated>2026-04-15T19:50:30Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Keep this minimal; critical positioning is inline in Lua.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1960</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1960"/>
		<updated>2026-04-15T19:49:42Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for gi, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('position', 'relative')&lt;br /&gt;
	node:css('display', 'inline-block')&lt;br /&gt;
	node:css('vertical-align', 'top')&lt;br /&gt;
	node:css('text-align', 'left')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('position', 'relative')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('height', '72px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local lineLeft = math.min(layout.rootCenter, partnerCenter)&lt;br /&gt;
		local lineWidth = math.abs(layout.rootCenter - partnerCenter)&lt;br /&gt;
&lt;br /&gt;
		if lineWidth &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('position', 'absolute')&lt;br /&gt;
			seg:css('top', '28px')&lt;br /&gt;
			seg:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
			seg:css('height', '2px')&lt;br /&gt;
			seg:css('background', '#bca88e')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-rootslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-partnerslot')&lt;br /&gt;
		slot:css('position', 'absolute')&lt;br /&gt;
		slot:css('top', '0')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:css('transform', 'translateX(-50%)')&lt;br /&gt;
		slot:css('z-index', '2')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('position', 'relative')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
		branch:css('height', '24px')&lt;br /&gt;
		branch:css('margin-top', '4px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('display', 'flex')&lt;br /&gt;
		childRow:css('align-items', 'flex-start')&lt;br /&gt;
		childRow:css('justify-content', 'flex-start')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('position', 'relative')&lt;br /&gt;
			groupWrap:css('display', 'inline-block')&lt;br /&gt;
			groupWrap:css('vertical-align', 'top')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('position', 'relative')&lt;br /&gt;
				childWrap:css('display', 'inline-block')&lt;br /&gt;
				childWrap:css('vertical-align', 'top')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('position', 'absolute')&lt;br /&gt;
				drop:css('top', '-10px')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
				drop:css('width', '2px')&lt;br /&gt;
				drop:css('height', '10px')&lt;br /&gt;
				drop:css('margin-left', '-1px')&lt;br /&gt;
				drop:css('background', '#bca88e')&lt;br /&gt;
				drop:css('z-index', '1')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local sourceAnchorAbs&lt;br /&gt;
			if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
				sourceAnchorAbs = selfLeft + layout.unionCenters[group.partnerName]&lt;br /&gt;
			else&lt;br /&gt;
				sourceAnchorAbs = rootCenterAbs&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = sourceAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('position', 'absolute')&lt;br /&gt;
			parentDrop:css('top', '0')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
			parentDrop:css('width', '2px')&lt;br /&gt;
			parentDrop:css('height', '14px')&lt;br /&gt;
			parentDrop:css('margin-left', '-1px')&lt;br /&gt;
			parentDrop:css('background', '#bca88e')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('position', 'absolute')&lt;br /&gt;
						bar:css('top', '14px')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
						bar:css('height', '2px')&lt;br /&gt;
						bar:css('background', '#bca88e')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('position', 'absolute')&lt;br /&gt;
				bar:css('top', '14px')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('height', '2px')&lt;br /&gt;
				bar:css('background', '#bca88e')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1959</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1959"/>
		<updated>2026-04-15T19:38:14Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree|root=Maddox Barlowe|mode=all}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree|root=Julia Laurence|mode=all}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1958</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1958"/>
		<updated>2026-04-15T19:37:02Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Supports multiple partners and&lt;br /&gt;
   union-specific descendant drops.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 58px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-cardslot {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  transform: translateX(-50%);&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionseg {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 28px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1957</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1957"/>
		<updated>2026-04-15T19:36:25Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local seen = {}&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) and not seen[link.otherParent] then&lt;br /&gt;
			seen[link.otherParent] = true&lt;br /&gt;
			table.insert(out, link.otherParent)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ua = findUnionBetween(people, personName, a)&lt;br /&gt;
		local ub = findUnionBetween(people, personName, b)&lt;br /&gt;
&lt;br /&gt;
		local da = ua and sortKeyDate(ua) or '9999-99-99'&lt;br /&gt;
		local db = ub and sortKeyDate(ub) or '9999-99-99'&lt;br /&gt;
&lt;br /&gt;
		if da ~= db then&lt;br /&gt;
			return da &amp;lt; db&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildPartnerLayoutForFamilyTree(partners)&lt;br /&gt;
	local partnerCenters = {}&lt;br /&gt;
	local unionCenters = {}&lt;br /&gt;
	local tempCenters = {}&lt;br /&gt;
&lt;br /&gt;
	local leftPartners, rightPartners = splitAroundCenter(partners)&lt;br /&gt;
&lt;br /&gt;
	local minCenter = 0&lt;br /&gt;
	local maxCenter = 0&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(leftPartners) do&lt;br /&gt;
		local idxFromRoot = #leftPartners - i + 1&lt;br /&gt;
		local c = -(idxFromRoot * 180)&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for i, name in ipairs(rightPartners) do&lt;br /&gt;
		local c = i * 180&lt;br /&gt;
		tempCenters[name] = c&lt;br /&gt;
		if c &amp;lt; minCenter then minCenter = c end&lt;br /&gt;
		if c &amp;gt; maxCenter then maxCenter = c end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local shift = -minCenter + 70&lt;br /&gt;
	local rootCenter = shift&lt;br /&gt;
	local rowWidth = (maxCenter - minCenter) + 140&lt;br /&gt;
&lt;br /&gt;
	for name, c in pairs(tempCenters) do&lt;br /&gt;
		local shifted = c + shift&lt;br /&gt;
		partnerCenters[name] = shifted&lt;br /&gt;
		unionCenters[name] = math.floor((rootCenter + shifted) / 2)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		rowWidth = math.max(rowWidth, 140),&lt;br /&gt;
		rootCenter = rootCenter,&lt;br /&gt;
		partnerCenters = partnerCenters,&lt;br /&gt;
		unionCenters = unionCenters&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
		local partnerName = nil&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'union::' .. link.otherParent&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
			partnerName = link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				partnerName = partnerName,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local displayPartners = getDisplayPartnersForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local layout = buildPartnerLayoutForFamilyTree(displayPartners)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for gi, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			partnerName = rawGroup.partnerName,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local topWidth = layout.rowWidth&lt;br /&gt;
	local nodeWidth = math.max(topWidth, groupsRowWidth, 140)&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - topWidth) / 2)&lt;br /&gt;
	local rootCenterAbs = selfLeft + layout.rootCenter&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(topWidth) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	-- union segments first&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local partnerCenter = layout.partnerCenters[partnerName]&lt;br /&gt;
		local left = math.min(layout.rootCenter, partnerCenter) + 70&lt;br /&gt;
		local width = math.abs(layout.rootCenter - partnerCenter) - 140&lt;br /&gt;
&lt;br /&gt;
		if width &amp;gt; 0 then&lt;br /&gt;
			local seg = selfRow:tag('div')&lt;br /&gt;
			seg:addClass('kbft-ft-unionseg')&lt;br /&gt;
			seg:css('left', tostring(left) .. 'px')&lt;br /&gt;
			seg:css('width', tostring(width) .. 'px')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- root card&lt;br /&gt;
	do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-rootslot')&lt;br /&gt;
		slot:css('left', tostring(layout.rootCenter) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		if focus then&lt;br /&gt;
			slot:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			slot:node(renderCard(people, personName))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- partner cards&lt;br /&gt;
	for _, partnerName in ipairs(displayPartners) do&lt;br /&gt;
		local slot = selfRow:tag('div')&lt;br /&gt;
		slot:addClass('kbft-ft-cardslot')&lt;br /&gt;
		slot:addClass('kbft-ft-partnerslot')&lt;br /&gt;
		slot:css('left', tostring(layout.partnerCenters[partnerName]) .. 'px')&lt;br /&gt;
		slot:node(renderCard(people, partnerName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local sourceAnchorAbs&lt;br /&gt;
			if group.anchorKind == 'union' and isRealValue(group.partnerName) and layout.unionCenters[group.partnerName] then&lt;br /&gt;
				sourceAnchorAbs = selfLeft + layout.unionCenters[group.partnerName]&lt;br /&gt;
			else&lt;br /&gt;
				sourceAnchorAbs = rootCenterAbs&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = sourceAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = rootCenterAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1956</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1956"/>
		<updated>2026-04-15T19:22:07Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Maddox Barlowe&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1955</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1955"/>
		<updated>2026-04-15T19:18:42Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1954</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1954"/>
		<updated>2026-04-15T19:15:05Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1953</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1953"/>
		<updated>2026-04-15T19:14:29Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Shared children can drop from union;&lt;br /&gt;
   solo/adoptive children can drop from the child only.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-groupwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1952</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1952"/>
		<updated>2026-04-15T19:13:48Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local UNION_CENTER = 170&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
local GROUP_GAP = 28&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	if #links == 0 then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerSeen = {}&lt;br /&gt;
	local partnerList = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			if not partnerSeen[link.otherParent] then&lt;br /&gt;
				partnerSeen[link.otherParent] = true&lt;br /&gt;
				table.insert(partnerList, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #partnerList == 1 then&lt;br /&gt;
		return partnerList[1]&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildChildGroupsForFamilyTree(people, personName, partnerName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local groupsByKey = {}&lt;br /&gt;
	local order = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		local key&lt;br /&gt;
		local anchorKind&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(partnerName) and isRealValue(link.otherParent) and link.otherParent == partnerName then&lt;br /&gt;
			key = 'union::' .. partnerName&lt;br /&gt;
			anchorKind = 'union'&lt;br /&gt;
		else&lt;br /&gt;
			key = 'self::' .. personName&lt;br /&gt;
			anchorKind = 'self'&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groupsByKey[key] then&lt;br /&gt;
			groupsByKey[key] = {&lt;br /&gt;
				anchorKind = anchorKind,&lt;br /&gt;
				children = {}&lt;br /&gt;
			}&lt;br /&gt;
			table.insert(order, key)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnique(groupsByKey[key].children, link.child)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, key in ipairs(order) do&lt;br /&gt;
		table.insert(out, groupsByKey[key])&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local rawGroups = buildChildGroupsForFamilyTree(people, personName, partnerName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childGroups = {}&lt;br /&gt;
	local groupsRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for gi, rawGroup in ipairs(rawGroups) do&lt;br /&gt;
		local group = {&lt;br /&gt;
			anchorKind = rawGroup.anchorKind,&lt;br /&gt;
			nodes = {},&lt;br /&gt;
			width = 0&lt;br /&gt;
		}&lt;br /&gt;
&lt;br /&gt;
		for i, childName in ipairs(rawGroup.children) do&lt;br /&gt;
			local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
			table.insert(group.nodes, childNode)&lt;br /&gt;
			group.width = group.width + childNode.width&lt;br /&gt;
			if i &amp;gt; 1 then&lt;br /&gt;
				group.width = group.width + CHILD_GAP&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(childGroups, group)&lt;br /&gt;
		groupsRowWidth = groupsRowWidth + group.width&lt;br /&gt;
		if gi &amp;gt; 1 then&lt;br /&gt;
			groupsRowWidth = groupsRowWidth + GROUP_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local nodeWidth = SLOT_WIDTH&lt;br /&gt;
	if groupsRowWidth &amp;gt; nodeWidth then&lt;br /&gt;
		nodeWidth = groupsRowWidth&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)&lt;br /&gt;
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER&lt;br /&gt;
	local unionAnchorAbs = selfLeft + UNION_CENTER&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfSlot = selfRow:tag('div')&lt;br /&gt;
	selfSlot:addClass('kbft-ft-selfslot')&lt;br /&gt;
&lt;br /&gt;
	local anchorWrap = selfSlot:tag('div')&lt;br /&gt;
	anchorWrap:addClass('kbft-ft-anchor')&lt;br /&gt;
	if focus then&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
	else&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local unionLine = selfSlot:tag('div')&lt;br /&gt;
	unionLine:addClass('kbft-ft-unionline')&lt;br /&gt;
	if not isRealValue(partnerName) then&lt;br /&gt;
		unionLine:addClass('kbft-ft-hidden')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerWrap = selfSlot:tag('div')&lt;br /&gt;
	partnerWrap:addClass('kbft-ft-partner')&lt;br /&gt;
	if isRealValue(partnerName) then&lt;br /&gt;
		partnerWrap:node(renderCard(people, partnerName))&lt;br /&gt;
	else&lt;br /&gt;
		partnerWrap:addClass('kbft-ft-partner-empty')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childGroups &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(groupsRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - groupsRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local groupAnchors = {}&lt;br /&gt;
		local runningGroupX = 0&lt;br /&gt;
		local groupsStart = math.floor((nodeWidth - groupsRowWidth) / 2)&lt;br /&gt;
&lt;br /&gt;
		for gi, group in ipairs(childGroups) do&lt;br /&gt;
			local groupWrap = childRow:tag('div')&lt;br /&gt;
			groupWrap:addClass('kbft-ft-groupwrap')&lt;br /&gt;
			groupWrap:css('width', tostring(group.width) .. 'px')&lt;br /&gt;
			if gi &amp;lt; #childGroups then&lt;br /&gt;
				groupWrap:css('margin-right', tostring(GROUP_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local childAnchorsAbs = {}&lt;br /&gt;
			local runningChildX = 0&lt;br /&gt;
&lt;br /&gt;
			for ci, childNode in ipairs(group.nodes) do&lt;br /&gt;
				local childWrap = groupWrap:tag('div')&lt;br /&gt;
				childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
				childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
				if ci &amp;lt; #group.nodes then&lt;br /&gt;
					childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				local drop = childWrap:tag('div')&lt;br /&gt;
				drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
				drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
				childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
				table.insert(&lt;br /&gt;
					childAnchorsAbs,&lt;br /&gt;
					groupsStart + runningGroupX + runningChildX + childNode.anchorX&lt;br /&gt;
				)&lt;br /&gt;
&lt;br /&gt;
				runningChildX = runningChildX + childNode.width + (ci &amp;lt; #group.nodes and CHILD_GAP or 0)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(groupAnchors, {&lt;br /&gt;
				sourceAnchorAbs = (group.anchorKind == 'union') and unionAnchorAbs or selfAnchorAbs,&lt;br /&gt;
				childAnchorsAbs = childAnchorsAbs&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			runningGroupX = runningGroupX + group.width + (gi &amp;lt; #childGroups and GROUP_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, info in ipairs(groupAnchors) do&lt;br /&gt;
			local sourceAnchorAbs = info.sourceAnchorAbs&lt;br /&gt;
			local childAbsAnchors = info.childAnchorsAbs&lt;br /&gt;
&lt;br /&gt;
			local parentDrop = branch:tag('div')&lt;br /&gt;
			parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
			parentDrop:css('left', tostring(sourceAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
			local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
			if #childAbsAnchors == 1 then&lt;br /&gt;
				local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
				if sourceAnchorAbs ~= onlyAnchor then&lt;br /&gt;
					local lineLeft = math.min(sourceAnchorAbs, onlyAnchor)&lt;br /&gt;
					local lineWidth = math.abs(sourceAnchorAbs - onlyAnchor)&lt;br /&gt;
					if lineWidth &amp;gt; 0 then&lt;br /&gt;
						local bar = branch:tag('div')&lt;br /&gt;
						bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
						bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
						bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			else&lt;br /&gt;
				local bar = branch:tag('div')&lt;br /&gt;
				bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
				bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
				bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = selfAnchorAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1951</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1951"/>
		<updated>2026-04-15T19:01:40Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1950</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1950"/>
		<updated>2026-04-15T18:59:56Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1949</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1949"/>
		<updated>2026-04-15T18:55:51Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
&lt;br /&gt;
-- forward declarations for rendering helpers used earlier in the file&lt;br /&gt;
local renderCard&lt;br /&gt;
local renderSingleCard&lt;br /&gt;
local renderCouple&lt;br /&gt;
local renderGenerationRow&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	if #links == 0 then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerSeen = {}&lt;br /&gt;
	local partnerList = {}&lt;br /&gt;
	local hasSolo = false&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			if not partnerSeen[link.otherParent] then&lt;br /&gt;
				partnerSeen[link.otherParent] = true&lt;br /&gt;
				table.insert(partnerList, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			hasSolo = true&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if hasSolo then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #partnerList == 1 then&lt;br /&gt;
		return partnerList[1]&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local childNames = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childNodes = {}&lt;br /&gt;
	local childRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for i, childName in ipairs(childNames) do&lt;br /&gt;
		local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
		table.insert(childNodes, childNode)&lt;br /&gt;
		childRowWidth = childRowWidth + childNode.width&lt;br /&gt;
		if i &amp;gt; 1 then&lt;br /&gt;
			childRowWidth = childRowWidth + CHILD_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local nodeWidth = SLOT_WIDTH&lt;br /&gt;
	if childRowWidth &amp;gt; nodeWidth then&lt;br /&gt;
		nodeWidth = childRowWidth&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)&lt;br /&gt;
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfSlot = selfRow:tag('div')&lt;br /&gt;
	selfSlot:addClass('kbft-ft-selfslot')&lt;br /&gt;
&lt;br /&gt;
	local anchorWrap = selfSlot:tag('div')&lt;br /&gt;
	anchorWrap:addClass('kbft-ft-anchor')&lt;br /&gt;
	if focus then&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
	else&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local unionLine = selfSlot:tag('div')&lt;br /&gt;
	unionLine:addClass('kbft-ft-unionline')&lt;br /&gt;
	if not isRealValue(partnerName) then&lt;br /&gt;
		unionLine:addClass('kbft-ft-hidden')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerWrap = selfSlot:tag('div')&lt;br /&gt;
	partnerWrap:addClass('kbft-ft-partner')&lt;br /&gt;
	if isRealValue(partnerName) then&lt;br /&gt;
		partnerWrap:node(renderCard(people, partnerName))&lt;br /&gt;
	else&lt;br /&gt;
		partnerWrap:addClass('kbft-ft-partner-empty')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childNodes &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(childRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - childRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childAbsAnchors = {}&lt;br /&gt;
		local runningX = 0&lt;br /&gt;
&lt;br /&gt;
		for i, childNode in ipairs(childNodes) do&lt;br /&gt;
			local childWrap = childRow:tag('div')&lt;br /&gt;
			childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
			childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
			if i &amp;lt; #childNodes then&lt;br /&gt;
				childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local drop = childWrap:tag('div')&lt;br /&gt;
			drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
			drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
			table.insert(childAbsAnchors, math.floor((nodeWidth - childRowWidth) / 2) + runningX + childNode.anchorX)&lt;br /&gt;
			runningX = runningX + childNode.width + (i &amp;lt; #childNodes and CHILD_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local parentDrop = branch:tag('div')&lt;br /&gt;
		parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
		parentDrop:css('left', tostring(selfAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
		local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
		if #childAbsAnchors == 1 then&lt;br /&gt;
			local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
&lt;br /&gt;
			if selfAnchorAbs ~= onlyAnchor then&lt;br /&gt;
				local lineLeft = math.min(selfAnchorAbs, onlyAnchor)&lt;br /&gt;
				local lineWidth = math.abs(selfAnchorAbs - onlyAnchor)&lt;br /&gt;
				if lineWidth &amp;gt; 0 then&lt;br /&gt;
					local bar = branch:tag('div')&lt;br /&gt;
					bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
					bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
					bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			local bar = branch:tag('div')&lt;br /&gt;
			bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
			bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
			bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = selfAnchorAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
renderCard = function(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderSingleCard = function(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderCouple = function(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
renderGenerationRow = function(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1948</id>
		<title>MediaWiki:Common.css</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=MediaWiki:Common.css&amp;diff=1948"/>
		<updated>2026-04-15T17:54:03Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;/* Layout + float */&lt;br /&gt;
.infobox.infobox-character .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.infobox.infobox-character .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f5f5f7, #ece7f1);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.infobox.infobox-character .infobox-image { text-align: center; padding: 8px; border-bottom: 1px solid #eee; }&lt;br /&gt;
.infobox.infobox-character .infobox-image img { max-width: 100%; height: auto; border-radius: 6px; }&lt;br /&gt;
.infobox.infobox-character .infobox-caption { color: #666; font-size: 0.85em; margin-top: 4px; }&lt;br /&gt;
&lt;br /&gt;
/* Rows */&lt;br /&gt;
.infobox.infobox-character .infobox-label {&lt;br /&gt;
  width: 42%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.infobox.infobox-character .infobox-data {&lt;br /&gt;
  width: 58%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  border-top: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows using :has() */&lt;br /&gt;
.infobox.infobox-character tr:has(.infobox-data .val:empty) { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Hide empty caption block */&lt;br /&gt;
.infobox.infobox-character .infobox-image:has(.infobox-caption:empty) .infobox-caption { display: none; }&lt;br /&gt;
&lt;br /&gt;
/* Mobile: stack it */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .infobox.infobox-character .infobox-table { float: none; margin: 0 auto 1em; }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ===== UNIVERSAL INFOBOX STYLES ===== */&lt;br /&gt;
&lt;br /&gt;
/* Wrapper floats box to the right */&lt;br /&gt;
.mw-parser-output .infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  margin: 0 0 1em 1em;&lt;br /&gt;
  clear: right;&lt;br /&gt;
  max-width: 320px;&lt;br /&gt;
  font-size: 0.95rem;&lt;br /&gt;
  line-height: 1.35;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
  border: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Table basics */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  background: #fff;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Title row */&lt;br /&gt;
.mw-parser-output .infobox .infobox-title {&lt;br /&gt;
  background: linear-gradient(to right, #f7f5f5, #f1ece7);&lt;br /&gt;
  color: #111;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
  font-family: &amp;quot;Georgia&amp;quot;, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Image + caption */&lt;br /&gt;
.mw-parser-output .infobox .infobox-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 8px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image img {&lt;br /&gt;
  max-width: 100%;&lt;br /&gt;
  height: auto;&lt;br /&gt;
  border-radius: 6px;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-caption {&lt;br /&gt;
  color: #666;&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Quote (for character boxes) */&lt;br /&gt;
.mw-parser-output .infobox .infobox-quote {&lt;br /&gt;
  font-style: italic;&lt;br /&gt;
  color: #444;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #eee;&lt;br /&gt;
  background: #faf9fb;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Label + data cells */&lt;br /&gt;
.mw-parser-output .infobox .infobox-label {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
  background: #fafafa;&lt;br /&gt;
  color: #333;&lt;br /&gt;
  font-weight: 600;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-data {&lt;br /&gt;
  width: 60%;&lt;br /&gt;
  padding: 8px 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Row dividers */&lt;br /&gt;
.mw-parser-output .infobox .infobox-table td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table th {&lt;br /&gt;
  border-bottom: 1px solid #ddd;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child td,&lt;br /&gt;
.mw-parser-output .infobox .infobox-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Hide empty rows gracefully */&lt;br /&gt;
.mw-parser-output .infobox tr:has(.infobox-data .val:empty) {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
.mw-parser-output .infobox .infobox-image:has(.infobox-caption:empty) .infobox-caption {&lt;br /&gt;
  display: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Mobile responsiveness */&lt;br /&gt;
@media (max-width: 700px) {&lt;br /&gt;
  .mw-parser-output .infobox {&lt;br /&gt;
    float: none;&lt;br /&gt;
    margin: 0 auto 1em;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
/*      INGREDIENT INFOBOX       */&lt;br /&gt;
/* ----------------------------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  float: right;&lt;br /&gt;
  width: 320px;&lt;br /&gt;
  margin: 0 0 20px 25px;&lt;br /&gt;
  padding: 0;&lt;br /&gt;
  background: #f9f5ec; /* warm parchment */&lt;br /&gt;
  border: 1px solid #8b6f47; /* old gold-brown */&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-title {&lt;br /&gt;
  background: #d8c8a5; /* darker parchment header */&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-size: 1.25em;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
  border-bottom: 1px solid #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-image {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  padding: 10px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table {&lt;br /&gt;
  width: 100%;&lt;br /&gt;
  border-collapse: collapse;&lt;br /&gt;
  font-size: 0.86em;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table th {&lt;br /&gt;
  width: 40%;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
  background: #e9dfc9;&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #c6b79b;&lt;br /&gt;
  color: #3a2d1f;&lt;br /&gt;
  font-weight: bold;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table td {&lt;br /&gt;
  padding: 6px 8px;&lt;br /&gt;
  border-bottom: 1px solid #d7cbb4;&lt;br /&gt;
  color: #2f251a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-table tr:last-child td,&lt;br /&gt;
.ingredient-table tr:last-child th {&lt;br /&gt;
  border-bottom: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- THEME VARIANTS FOR INGREDIENT BOXES ---------- */&lt;br /&gt;
&lt;br /&gt;
/* Default (what you already have) */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default {&lt;br /&gt;
  border-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-default .ingredient-title {&lt;br /&gt;
  background: #d8c8a5;&lt;br /&gt;
  border-bottom-color: #8b6f47;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Healing / herbal: soft green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-healing .ingredient-title,&lt;br /&gt;
.ingredient-infobox.ingredient-theme-botanical .ingredient-title {&lt;br /&gt;
  background: #cfdcc2;&lt;br /&gt;
  border-bottom-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Poisons / venoms: moody purple-green */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-poison .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #3c234e, #234031);&lt;br /&gt;
  color: #f4eefc;&lt;br /&gt;
  border-bottom-color: #2b1a38;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Catalysts / amplifiers: rich gold */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-catalyst .ingredient-title {&lt;br /&gt;
  background: linear-gradient(to right, #f1d48d, #e9b956);&lt;br /&gt;
  color: #402b0e;&lt;br /&gt;
  border-bottom-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Dark / necromantic: deep burgundy */&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
.ingredient-infobox.ingredient-theme-dark .ingredient-title {&lt;br /&gt;
  background: #7b2e36;&lt;br /&gt;
  color: #f9f2f3;&lt;br /&gt;
  border-bottom-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- FAKE PARCHMENT CURLS ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-infobox {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  border-radius: 4px;&lt;br /&gt;
  overflow: visible; /* let curls peek out */&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* bottom-right curl */&lt;br /&gt;
.ingredient-infobox::after {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 26px;&lt;br /&gt;
  height: 26px;&lt;br /&gt;
  right: -1px;&lt;br /&gt;
  bottom: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 0 0, rgba(0,0,0,0.25) 0, rgba(0,0,0,0.25) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(135deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-bottom-right-radius: 4px;&lt;br /&gt;
  box-shadow: -2px -2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(4px,4px) rotate(3deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* top-left curl */&lt;br /&gt;
.ingredient-infobox::before {&lt;br /&gt;
  content: &amp;quot;&amp;quot;;&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  width: 22px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  left: -1px;&lt;br /&gt;
  top: -1px;&lt;br /&gt;
  background:&lt;br /&gt;
    radial-gradient(circle at 100% 100%, rgba(0,0,0,0.2) 0, rgba(0,0,0,0.2) 40%, transparent 41%) ,&lt;br /&gt;
    linear-gradient(315deg, #f9f5ec 0, #d8c8a5 60%, #c2aa80 100%);&lt;br /&gt;
  border-top-left-radius: 4px;&lt;br /&gt;
  box-shadow: 2px 2px 4px rgba(0,0,0,0.25);&lt;br /&gt;
  transform: translate(-3px,-3px) rotate(-2deg);&lt;br /&gt;
  pointer-events: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* ---------- INGREDIENT CATEGORY HEADER BOX ---------- */&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header {&lt;br /&gt;
  background: #f9f5ec;&lt;br /&gt;
  border: 1px solid #8b6f47;&lt;br /&gt;
  box-shadow: 0 0 6px rgba(0,0,0,0.25);&lt;br /&gt;
  padding: 12px 16px;&lt;br /&gt;
  margin: 0 0 1em 0;&lt;br /&gt;
  font-family: Georgia, &amp;quot;Times New Roman&amp;quot;, serif;&lt;br /&gt;
  max-width: 720px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* Let it share the same theme colors as ingredient boxes */&lt;br /&gt;
.ingredient-category-header.ingredient-theme-healing,&lt;br /&gt;
.ingredient-category-header.ingredient-theme-botanical {&lt;br /&gt;
  border-color: #5c7e4b;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-poison {&lt;br /&gt;
  border-color: #4b2f5c;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-catalyst {&lt;br /&gt;
  border-color: #b6862e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.ingredient-category-header.ingredient-theme-dark {&lt;br /&gt;
  border-color: #5b1f26;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   KBFT CORE&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-tree {&lt;br /&gt;
  text-align: center;&lt;br /&gt;
  background: #f5f1eb;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 18px;&lt;br /&gt;
  padding: 28px 24px 34px;&lt;br /&gt;
  margin: 1.25em 0;&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-title {&lt;br /&gt;
  font-size: 1.1rem;&lt;br /&gt;
  font-weight: 700;&lt;br /&gt;
  margin-bottom: 1.5rem;&lt;br /&gt;
  color: #3d2e1f;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-generation {&lt;br /&gt;
  margin: 22px 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-connector {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 28px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 34px !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-row,&lt;br /&gt;
.mw-parser-output .kbft-desc-row {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  align-items: flex-start !important;&lt;br /&gt;
  gap: 26px !important;&lt;br /&gt;
  flex-wrap: nowrap !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focal-col,&lt;br /&gt;
.mw-parser-output .kbft-branch-col {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  flex: 0 0 auto !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-single {&lt;br /&gt;
  display: inline-flex !important;&lt;br /&gt;
  flex-direction: column !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: flex-start !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  min-width: 0 !important;&lt;br /&gt;
  max-width: none !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-couple {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  align-items: center !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-marriage-line {&lt;br /&gt;
  width: 36px !important;&lt;br /&gt;
  height: 2px !important;&lt;br /&gt;
  background: #bca88e !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card {&lt;br /&gt;
  background: #fcfaf7;&lt;br /&gt;
  border: 1px solid #ccb79e;&lt;br /&gt;
  border-radius: 12px;&lt;br /&gt;
  padding: 10px 14px;&lt;br /&gt;
  min-width: 118px;&lt;br /&gt;
  box-shadow: 0 1px 4px rgba(0,0,0,0.08);&lt;br /&gt;
  line-height: 1.25;&lt;br /&gt;
  display: inline-block !important;&lt;br /&gt;
  width: auto !important;&lt;br /&gt;
  max-width: 170px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-card a {&lt;br /&gt;
  text-decoration: none;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-focus-card {&lt;br /&gt;
  border: 2px solid #a88c5a;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-years {&lt;br /&gt;
  font-size: 0.78em;&lt;br /&gt;
  color: #6e5a42;&lt;br /&gt;
  margin-top: 5px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta {&lt;br /&gt;
  font-size: 0.85em;&lt;br /&gt;
  color: #5f4b36;&lt;br /&gt;
  margin-top: 10px;&lt;br /&gt;
  margin-bottom: 6px;&lt;br /&gt;
  min-height: 1.2em;&lt;br /&gt;
  text-align: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-union-meta-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-child-down {&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 22px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 4px 0 8px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-children {&lt;br /&gt;
  display: flex !important;&lt;br /&gt;
  flex-direction: row !important;&lt;br /&gt;
  flex-wrap: wrap !important;&lt;br /&gt;
  justify-content: center !important;&lt;br /&gt;
  gap: 10px !important;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
/* =========================&lt;br /&gt;
   FAMILYTREE MODE&lt;br /&gt;
   Child drops connect to the child anchor,&lt;br /&gt;
   not the spouse card.&lt;br /&gt;
   ========================= */&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree {&lt;br /&gt;
  overflow-x: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-familytree-wrap {&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  min-width: max-content;&lt;br /&gt;
  padding: 6px 12px 14px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-node {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
  text-align: left;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfrow {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-selfslot {&lt;br /&gt;
  width: 340px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: center;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-anchor {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-unionline {&lt;br /&gt;
  width: 32px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  margin: 0 8px;&lt;br /&gt;
  flex: 0 0 auto;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner {&lt;br /&gt;
  width: 140px;&lt;br /&gt;
  display: flex;&lt;br /&gt;
  justify-content: center;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-partner-empty {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-hidden {&lt;br /&gt;
  visibility: hidden;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-branch {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  height: 24px;&lt;br /&gt;
  margin-top: 4px;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-parentdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 0;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 14px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenbar {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: 14px;&lt;br /&gt;
  height: 2px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childrenrow {&lt;br /&gt;
  display: flex;&lt;br /&gt;
  align-items: flex-start;&lt;br /&gt;
  justify-content: flex-start;&lt;br /&gt;
  margin-top: 0;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childwrap {&lt;br /&gt;
  position: relative;&lt;br /&gt;
  display: inline-block;&lt;br /&gt;
  vertical-align: top;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
.mw-parser-output .kbft-ft-childdrop {&lt;br /&gt;
  position: absolute;&lt;br /&gt;
  top: -10px;&lt;br /&gt;
  width: 2px;&lt;br /&gt;
  height: 10px;&lt;br /&gt;
  margin-left: -1px;&lt;br /&gt;
  background: #bca88e;&lt;br /&gt;
  z-index: 1;&lt;br /&gt;
}&lt;br /&gt;
&lt;br /&gt;
@media (max-width: 900px) {&lt;br /&gt;
  .mw-parser-output .kbft-tree {&lt;br /&gt;
    padding: 20px 14px 24px;&lt;br /&gt;
  }&lt;br /&gt;
&lt;br /&gt;
  .mw-parser-output .kbft-row {&lt;br /&gt;
    gap: 22px !important;&lt;br /&gt;
  }&lt;br /&gt;
}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1947</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1947"/>
		<updated>2026-04-15T17:53:08Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
local SLOT_WIDTH = 340&lt;br /&gt;
local ANCHOR_CENTER = 90&lt;br /&gt;
local CHILD_GAP = 24&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal + family cluster&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			table.insert(out, link)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local ao = tonumber(a.birthOrder) or 999&lt;br /&gt;
		local bo = tonumber(b.birthOrder) or 999&lt;br /&gt;
		if ao ~= bo then&lt;br /&gt;
			return ao &amp;lt; bo&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.child] and people[a.child].displayName) or a.child&lt;br /&gt;
		local bd = (people[b.child] and people[b.child].displayName) or b.child&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, link in ipairs(getChildLinksOf(people, personName, includeNonBiological)) do&lt;br /&gt;
		addUnique(out, link.child)&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Traditional descendant familytree helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
	local links = getChildLinksOf(people, personName, includeNonBiological)&lt;br /&gt;
	if #links == 0 then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerSeen = {}&lt;br /&gt;
	local partnerList = {}&lt;br /&gt;
	local hasSolo = false&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(links) do&lt;br /&gt;
		if isRealValue(link.otherParent) then&lt;br /&gt;
			if not partnerSeen[link.otherParent] then&lt;br /&gt;
				partnerSeen[link.otherParent] = true&lt;br /&gt;
				table.insert(partnerList, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			hasSolo = true&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if hasSolo then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #partnerList == 1 then&lt;br /&gt;
		return partnerList[1]&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyTreeNode(people, personName, includeNonBiological, focus)&lt;br /&gt;
	local childNames = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local partnerName = chooseDisplayPartnerForFamilyTree(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local childNodes = {}&lt;br /&gt;
	local childRowWidth = 0&lt;br /&gt;
&lt;br /&gt;
	for i, childName in ipairs(childNames) do&lt;br /&gt;
		local childNode = buildFamilyTreeNode(people, childName, includeNonBiological, false)&lt;br /&gt;
		table.insert(childNodes, childNode)&lt;br /&gt;
		childRowWidth = childRowWidth + childNode.width&lt;br /&gt;
		if i &amp;gt; 1 then&lt;br /&gt;
			childRowWidth = childRowWidth + CHILD_GAP&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local nodeWidth = SLOT_WIDTH&lt;br /&gt;
	if childRowWidth &amp;gt; nodeWidth then&lt;br /&gt;
		nodeWidth = childRowWidth&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local selfLeft = math.floor((nodeWidth - SLOT_WIDTH) / 2)&lt;br /&gt;
	local selfAnchorAbs = selfLeft + ANCHOR_CENTER&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-ft-node')&lt;br /&gt;
	node:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfRow = node:tag('div')&lt;br /&gt;
	selfRow:addClass('kbft-ft-selfrow')&lt;br /&gt;
	selfRow:css('width', tostring(SLOT_WIDTH) .. 'px')&lt;br /&gt;
	selfRow:css('margin-left', tostring(selfLeft) .. 'px')&lt;br /&gt;
&lt;br /&gt;
	local selfSlot = selfRow:tag('div')&lt;br /&gt;
	selfSlot:addClass('kbft-ft-selfslot')&lt;br /&gt;
&lt;br /&gt;
	local anchorWrap = selfSlot:tag('div')&lt;br /&gt;
	anchorWrap:addClass('kbft-ft-anchor')&lt;br /&gt;
	if focus then&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName, nil, 'kbft-focus-card'))&lt;br /&gt;
	else&lt;br /&gt;
		anchorWrap:node(renderCard(people, personName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local unionLine = selfSlot:tag('div')&lt;br /&gt;
	unionLine:addClass('kbft-ft-unionline')&lt;br /&gt;
	if not isRealValue(partnerName) then&lt;br /&gt;
		unionLine:addClass('kbft-ft-hidden')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local partnerWrap = selfSlot:tag('div')&lt;br /&gt;
	partnerWrap:addClass('kbft-ft-partner')&lt;br /&gt;
	if isRealValue(partnerName) then&lt;br /&gt;
		partnerWrap:node(renderCard(people, partnerName))&lt;br /&gt;
	else&lt;br /&gt;
		partnerWrap:addClass('kbft-ft-partner-empty')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #childNodes &amp;gt; 0 then&lt;br /&gt;
		local branch = node:tag('div')&lt;br /&gt;
		branch:addClass('kbft-ft-branch')&lt;br /&gt;
		branch:css('width', tostring(nodeWidth) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childRow = node:tag('div')&lt;br /&gt;
		childRow:addClass('kbft-ft-childrenrow')&lt;br /&gt;
		childRow:css('width', tostring(childRowWidth) .. 'px')&lt;br /&gt;
		childRow:css('margin-left', tostring(math.floor((nodeWidth - childRowWidth) / 2)) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local childAbsAnchors = {}&lt;br /&gt;
		local runningX = 0&lt;br /&gt;
&lt;br /&gt;
		for i, childNode in ipairs(childNodes) do&lt;br /&gt;
			local childWrap = childRow:tag('div')&lt;br /&gt;
			childWrap:addClass('kbft-ft-childwrap')&lt;br /&gt;
			childWrap:css('width', tostring(childNode.width) .. 'px')&lt;br /&gt;
			if i &amp;lt; #childNodes then&lt;br /&gt;
				childWrap:css('margin-right', tostring(CHILD_GAP) .. 'px')&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			local drop = childWrap:tag('div')&lt;br /&gt;
			drop:addClass('kbft-ft-childdrop')&lt;br /&gt;
			drop:css('left', tostring(childNode.anchorX) .. 'px')&lt;br /&gt;
&lt;br /&gt;
			childWrap:wikitext(childNode.html)&lt;br /&gt;
&lt;br /&gt;
			table.insert(childAbsAnchors, math.floor((nodeWidth - childRowWidth) / 2) + runningX + childNode.anchorX)&lt;br /&gt;
			runningX = runningX + childNode.width + (i &amp;lt; #childNodes and CHILD_GAP or 0)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local parentDrop = branch:tag('div')&lt;br /&gt;
		parentDrop:addClass('kbft-ft-parentdrop')&lt;br /&gt;
		parentDrop:css('left', tostring(selfAnchorAbs) .. 'px')&lt;br /&gt;
&lt;br /&gt;
		local firstAnchor = childAbsAnchors[1]&lt;br /&gt;
		local lastAnchor = childAbsAnchors[#childAbsAnchors]&lt;br /&gt;
&lt;br /&gt;
		if #childAbsAnchors == 1 then&lt;br /&gt;
			local onlyAnchor = childAbsAnchors[1]&lt;br /&gt;
&lt;br /&gt;
			if selfAnchorAbs ~= onlyAnchor then&lt;br /&gt;
				local lineLeft = math.min(selfAnchorAbs, onlyAnchor)&lt;br /&gt;
				local lineWidth = math.abs(selfAnchorAbs - onlyAnchor)&lt;br /&gt;
				if lineWidth &amp;gt; 0 then&lt;br /&gt;
					local bar = branch:tag('div')&lt;br /&gt;
					bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
					bar:css('left', tostring(lineLeft) .. 'px')&lt;br /&gt;
					bar:css('width', tostring(lineWidth) .. 'px')&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		else&lt;br /&gt;
			local bar = branch:tag('div')&lt;br /&gt;
			bar:addClass('kbft-ft-childrenbar')&lt;br /&gt;
			bar:css('left', tostring(firstAnchor) .. 'px')&lt;br /&gt;
			bar:css('width', tostring(lastAnchor - firstAnchor) .. 'px')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		html = tostring(node),&lt;br /&gt;
		width = nodeWidth,&lt;br /&gt;
		anchorX = selfAnchorAbs&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Focal tree rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Generic rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local tree = buildFamilyTreeNode(people, root, includeNonBiological, true)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
	node:addClass('kbft-familytree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local wrap = node:tag('div')&lt;br /&gt;
	wrap:addClass('kbft-familytree-wrap')&lt;br /&gt;
	wrap:wikitext(tree.html)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyTreeForRoot(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1946</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1946"/>
		<updated>2026-04-15T17:17:36Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familytree&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1945</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1945"/>
		<updated>2026-04-15T17:16:49Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		-- Build parent/child edges from childLinks so relationshipType is preserved&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		-- Partner edges&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			addUnique(out, link.child)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildDescendantGenerations(people, root, includeNonBiological)&lt;br /&gt;
	local generations = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local currentGen = { root }&lt;br /&gt;
&lt;br /&gt;
	while #currentGen &amp;gt; 0 do&lt;br /&gt;
		table.insert(generations, currentGen)&lt;br /&gt;
&lt;br /&gt;
		local nextGen = {}&lt;br /&gt;
&lt;br /&gt;
		for _, personName in ipairs(currentGen) do&lt;br /&gt;
			if not visited[personName] then&lt;br /&gt;
				visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
				local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
				for _, child in ipairs(children) do&lt;br /&gt;
					if not visited[child] then&lt;br /&gt;
						addUnique(nextGen, child)&lt;br /&gt;
					end&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		sortNames(people, nextGen)&lt;br /&gt;
		currentGen = nextGen&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return generations&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add root&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	-- Add descendants&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add partners only in extended mode&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantTree(people, root, mode)&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local generations = buildDescendantGenerations(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	for i, genList in ipairs(generations) do&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(genList) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
		-- add connector between generations&lt;br /&gt;
		if i &amp;lt; #generations then&lt;br /&gt;
			node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familytree(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderDescendantTree(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1944</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1944"/>
		<updated>2026-04-15T17:09:43Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Direct Blood:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Blood and Adoptive: &lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=all&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
Extended Family Cluster:&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=extended&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1943</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1943"/>
		<updated>2026-04-15T17:08:39Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
|mode=blood&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1942</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1942"/>
		<updated>2026-04-15T17:08:02Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		-- Build parent/child edges from childLinks so relationshipType is preserved&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		-- Partner edges&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			addUnique(out, link.child)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root, mode)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local includeNonBiological = (mode == 'all' or mode == 'extended')&lt;br /&gt;
	local includePartners = (mode == 'extended')&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if isRealValue(name) and not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add root&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	-- Add descendants&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add partners only in extended mode&lt;br /&gt;
	if includePartners then&lt;br /&gt;
		local snapshot = {}&lt;br /&gt;
		for i, name in ipairs(cluster) do&lt;br /&gt;
			snapshot[i] = name&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, name in ipairs(snapshot) do&lt;br /&gt;
			local person = people[name]&lt;br /&gt;
			if person then&lt;br /&gt;
				for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
					addPerson(partner)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root, mode)&lt;br /&gt;
	local members = buildFamilyCluster(people, root, mode)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	local title = 'Family Index: ' .. makeLink(root, root)&lt;br /&gt;
	if mode == 'extended' then&lt;br /&gt;
		title = title .. ' (extended)'&lt;br /&gt;
	elseif mode == 'all' then&lt;br /&gt;
		title = title .. ' (including adoptive)'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(title)&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local mode = getArg(frame, 'mode') or 'blood'&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root, mode)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1941</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1941"/>
		<updated>2026-04-15T17:03:21Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|familyindex&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1940</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1940"/>
		<updated>2026-04-15T17:02:51Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		-- Build parent/child edges from childLinks so relationshipType is preserved&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		-- Partner edges&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			addUnique(out, link.child)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildFamilyCluster(people, root)&lt;br /&gt;
	local cluster = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function addPerson(name)&lt;br /&gt;
		if not visited[name] then&lt;br /&gt;
			visited[name] = true&lt;br /&gt;
			table.insert(cluster, name)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add root&lt;br /&gt;
	addPerson(root)&lt;br /&gt;
&lt;br /&gt;
	-- Add descendants&lt;br /&gt;
	local descendants = getDescendants(people, root, true)&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		addPerson(name)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	-- Add partners of everyone in cluster&lt;br /&gt;
	local initial = { unpack(cluster) }&lt;br /&gt;
&lt;br /&gt;
	for _, name in ipairs(initial) do&lt;br /&gt;
		local person = people[name]&lt;br /&gt;
		if person then&lt;br /&gt;
			for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
				addPerson(partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, cluster)&lt;br /&gt;
	return cluster&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFamilyIndex(people, root)&lt;br /&gt;
	local members = buildFamilyCluster(people, root)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Index: ' .. makeLink(root, root))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(members) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.familyindex(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderFamilyIndex(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1939</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1939"/>
		<updated>2026-04-15T16:08:14Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current Test Page as we build the Global Family Tree and its systems. &lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|descendants&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|descendants&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|include=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Talk:Family_Tree_Test&amp;diff=1938</id>
		<title>Talk:Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Talk:Family_Tree_Test&amp;diff=1938"/>
		<updated>2026-04-15T16:07:44Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: Created page with &amp;quot;Current test page as we build the global family tree and its systems.&amp;quot;&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;Current test page as we build the global family tree and its systems.&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Evander_Barlowe&amp;diff=1937</id>
		<title>Evander Barlowe</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Evander_Barlowe&amp;diff=1937"/>
		<updated>2026-04-15T16:06:22Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: Created blank page&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1936</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1936"/>
		<updated>2026-04-15T16:05:46Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|descendants&lt;br /&gt;
|root=William Harold Laurence&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|descendants&lt;br /&gt;
|root=Julia Laurence&lt;br /&gt;
|include=all&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1935</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1935"/>
		<updated>2026-04-15T16:04:46Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		-- Build parent/child edges from childLinks so relationshipType is preserved&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		-- Partner edges&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Descendant traversal&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[personName]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return out&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local rel = mw.ustring.lower(trim(link.relationshipType) or '')&lt;br /&gt;
		local isNonBiological = rel:find('adopt') or rel:find('step')&lt;br /&gt;
&lt;br /&gt;
		if includeNonBiological or not isNonBiological then&lt;br /&gt;
			addUnique(out, link.child)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getDescendants(people, root, includeNonBiological)&lt;br /&gt;
	local results = {}&lt;br /&gt;
	local visited = {}&lt;br /&gt;
&lt;br /&gt;
	local function walk(personName)&lt;br /&gt;
		if visited[personName] then&lt;br /&gt;
			return&lt;br /&gt;
		end&lt;br /&gt;
		visited[personName] = true&lt;br /&gt;
&lt;br /&gt;
		local children = getChildrenOf(people, personName, includeNonBiological)&lt;br /&gt;
		for _, child in ipairs(children) do&lt;br /&gt;
			if not visited[child] then&lt;br /&gt;
				addUnique(results, child)&lt;br /&gt;
				walk(child)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	walk(root)&lt;br /&gt;
	sortNames(people, results)&lt;br /&gt;
	return results&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.descendants(frame)&lt;br /&gt;
	local root = getArg(frame, 'root')&lt;br /&gt;
	local includeAll = getArg(frame, 'include')&lt;br /&gt;
	local includeNonBiological = (includeAll == 'all')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(root) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |root=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local descendants = getDescendants(people, root, includeNonBiological)&lt;br /&gt;
&lt;br /&gt;
	if #descendants == 0 then&lt;br /&gt;
		return &amp;quot;No descendants found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, name in ipairs(descendants) do&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		table.insert(out, makeLink(name, displayName))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; • &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Kathryn_Laurence&amp;diff=1934</id>
		<title>Kathryn Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Kathryn_Laurence&amp;diff=1934"/>
		<updated>2026-04-15T15:59:00Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: Redirected page to Kathryn Barlowe&lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;#REDIRECT [[Kathryn Barlowe]]&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1933</id>
		<title>Family Tree Test</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Family_Tree_Test&amp;diff=1933"/>
		<updated>2026-04-15T15:55:53Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{#cargo_query:&lt;br /&gt;
tables=ParentChild&lt;br /&gt;
|fields=Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Unions&lt;br /&gt;
|fields=UnionID,Partner1,Partner2,UnionType,Status&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#cargo_query:&lt;br /&gt;
tables=Characters&lt;br /&gt;
|fields=Page,DisplayName&lt;br /&gt;
|limit=100&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|connected|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|profile|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Julia Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Rosalie Laurence}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|tree|root=Maddox Barlowe}}&lt;br /&gt;
&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Rosalie Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Julia Laurence&lt;br /&gt;
|to=Maddox Barlowe&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Roisin Byrne&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
{{#invoke:FamilyTree|path&lt;br /&gt;
|from=Maddox Barlowe&lt;br /&gt;
|to=Caroline Dobbs&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1932</id>
		<title>Module:FamilyTree</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Module:FamilyTree&amp;diff=1932"/>
		<updated>2026-04-15T15:54:29Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;local p = {}&lt;br /&gt;
&lt;br /&gt;
local cargo = mw.ext.cargo&lt;br /&gt;
local html = mw.html&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function trim(s)&lt;br /&gt;
	if s == nil then return nil end&lt;br /&gt;
	s = tostring(s)&lt;br /&gt;
	s = mw.text.trim(s)&lt;br /&gt;
	if s == '' then return nil end&lt;br /&gt;
	return s&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function isRealValue(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not v then return false end&lt;br /&gt;
	local lowered = mw.ustring.lower(v)&lt;br /&gt;
	return lowered ~= 'unknown' and lowered ~= 'none' and lowered ~= 'n/a'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function addUnique(list, value)&lt;br /&gt;
	if not isRealValue(value) then return end&lt;br /&gt;
	for _, existing in ipairs(list) do&lt;br /&gt;
		if existing == value then return end&lt;br /&gt;
	end&lt;br /&gt;
	table.insert(list, value)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function uniq(list)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	for _, v in ipairs(list or {}) do&lt;br /&gt;
		if isRealValue(v) and not seen[v] then&lt;br /&gt;
			seen[v] = true&lt;br /&gt;
			table.insert(out, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getArg(frame, key)&lt;br /&gt;
	local v = frame.args[key]&lt;br /&gt;
	if isRealValue(v) then return trim(v) end&lt;br /&gt;
	local parent = frame:getParent()&lt;br /&gt;
	if parent then&lt;br /&gt;
		v = parent.args[key]&lt;br /&gt;
		if isRealValue(v) then return trim(v) end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getRoot(frame)&lt;br /&gt;
	return getArg(frame, 'root') or getArg(frame, 'person') or mw.title.getCurrentTitle().text&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function makeLink(name, displayName)&lt;br /&gt;
	if not isRealValue(name) then return '' end&lt;br /&gt;
	displayName = trim(displayName) or name&lt;br /&gt;
	return string.format('[[%s|%s]]', name, displayName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function ensurePerson(people, name)&lt;br /&gt;
	name = trim(name)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
&lt;br /&gt;
	if not people[name] then&lt;br /&gt;
		people[name] = {&lt;br /&gt;
			name = name,&lt;br /&gt;
			displayName = name,&lt;br /&gt;
			parents = {},&lt;br /&gt;
			children = {},&lt;br /&gt;
			partners = {},&lt;br /&gt;
			unions = {},&lt;br /&gt;
			childLinks = {}&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people[name]&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortNames(people, names)&lt;br /&gt;
	table.sort(names, function(a, b)&lt;br /&gt;
		local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
		local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function splitAroundCenter(items)&lt;br /&gt;
	local left, right = {}, {}&lt;br /&gt;
	local n = #items&lt;br /&gt;
	local leftCount = math.floor(n / 2)&lt;br /&gt;
&lt;br /&gt;
	for i, v in ipairs(items) do&lt;br /&gt;
		if i &amp;lt;= leftCount then&lt;br /&gt;
			table.insert(left, v)&lt;br /&gt;
		else&lt;br /&gt;
			table.insert(right, v)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return left, right&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function extractYear(v)&lt;br /&gt;
	v = trim(v)&lt;br /&gt;
	if not isRealValue(v) then return nil end&lt;br /&gt;
	return tostring(v):match('^(%d%d%d%d)') or tostring(v)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function sortKeyDate(union)&lt;br /&gt;
	if not union then return '9999-99-99' end&lt;br /&gt;
	return trim(union.marriageDate)&lt;br /&gt;
		or trim(union.startDate)&lt;br /&gt;
		or trim(union.engagementDate)&lt;br /&gt;
		or trim(union.endDate)&lt;br /&gt;
		or '9999-99-99'&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Data loading&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function loadCharacters()&lt;br /&gt;
	local results = cargo.query('Characters', 'Page,DisplayName', { limit = 5000 })&lt;br /&gt;
	local people = {}&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local page = trim(row.Page)&lt;br /&gt;
		local displayName = trim(row.DisplayName)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(page) then&lt;br /&gt;
			people[page] = {&lt;br /&gt;
				name = page,&lt;br /&gt;
				displayName = displayName or page,&lt;br /&gt;
				parents = {},&lt;br /&gt;
				children = {},&lt;br /&gt;
				partners = {},&lt;br /&gt;
				unions = {},&lt;br /&gt;
				childLinks = {}&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadParentChild(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'ParentChild',&lt;br /&gt;
		'Child,Parent1,Parent2,UnionID,RelationshipType,BirthOrder',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local child = trim(row.Child)&lt;br /&gt;
		local p1 = trim(row.Parent1)&lt;br /&gt;
		local p2 = trim(row.Parent2)&lt;br /&gt;
		local unionID = trim(row.UnionID)&lt;br /&gt;
		local relationshipType = trim(row.RelationshipType)&lt;br /&gt;
		local birthOrder = tonumber(trim(row.BirthOrder)) or 999&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(child) then&lt;br /&gt;
			ensurePerson(people, child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p1) then&lt;br /&gt;
				ensurePerson(people, p1)&lt;br /&gt;
				addUnique(people[child].parents, p1)&lt;br /&gt;
				addUnique(people[p1].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p1].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p2,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(p2) then&lt;br /&gt;
				ensurePerson(people, p2)&lt;br /&gt;
				addUnique(people[child].parents, p2)&lt;br /&gt;
				addUnique(people[p2].children, child)&lt;br /&gt;
&lt;br /&gt;
				table.insert(people[p2].childLinks, {&lt;br /&gt;
					child = child,&lt;br /&gt;
					otherParent = p1,&lt;br /&gt;
					unionID = unionID,&lt;br /&gt;
					relationshipType = relationshipType,&lt;br /&gt;
					birthOrder = birthOrder&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadUnions(people)&lt;br /&gt;
	local results = cargo.query(&lt;br /&gt;
		'Unions',&lt;br /&gt;
		'UnionID,Partner1,Partner2,UnionType,Status,StartDate,EndDate,MarriageDate,DivorceDate,EngagementDate',&lt;br /&gt;
		{ limit = 5000 }&lt;br /&gt;
	)&lt;br /&gt;
&lt;br /&gt;
	for _, row in ipairs(results) do&lt;br /&gt;
		local p1 = trim(row.Partner1)&lt;br /&gt;
		local p2 = trim(row.Partner2)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) then ensurePerson(people, p1) end&lt;br /&gt;
		if isRealValue(p2) then ensurePerson(people, p2) end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(p1) and isRealValue(p2) then&lt;br /&gt;
			addUnique(people[p1].partners, p2)&lt;br /&gt;
			addUnique(people[p2].partners, p1)&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p1].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p2,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
&lt;br /&gt;
			table.insert(people[p2].unions, {&lt;br /&gt;
				unionID = trim(row.UnionID),&lt;br /&gt;
				partner = p1,&lt;br /&gt;
				unionType = trim(row.UnionType),&lt;br /&gt;
				status = trim(row.Status),&lt;br /&gt;
				startDate = trim(row.StartDate),&lt;br /&gt;
				endDate = trim(row.EndDate),&lt;br /&gt;
				marriageDate = trim(row.MarriageDate),&lt;br /&gt;
				divorceDate = trim(row.DivorceDate),&lt;br /&gt;
				engagementDate = trim(row.EngagementDate)&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function finalizePeople(people)&lt;br /&gt;
	for _, person in pairs(people) do&lt;br /&gt;
		person.parents = uniq(person.parents)&lt;br /&gt;
		person.children = uniq(person.children)&lt;br /&gt;
		person.partners = uniq(person.partners)&lt;br /&gt;
	end&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function loadData()&lt;br /&gt;
	local people = loadCharacters()&lt;br /&gt;
	loadParentChild(people)&lt;br /&gt;
	loadUnions(people)&lt;br /&gt;
	finalizePeople(people)&lt;br /&gt;
	return people&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Relationship helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function relationshipBadge(relType)&lt;br /&gt;
	if not isRealValue(relType) then return nil end&lt;br /&gt;
	local t = mw.ustring.lower(relType)&lt;br /&gt;
	if t:find('adopt') then return 'adopted' end&lt;br /&gt;
	if t:find('step') then return 'step' end&lt;br /&gt;
	if t:find('bio') then return nil end&lt;br /&gt;
	return relType&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findUnionBetween(people, name1, name2)&lt;br /&gt;
	if not isRealValue(name1) or not isRealValue(name2) then return nil end&lt;br /&gt;
	local person = people[name1]&lt;br /&gt;
	if not person or not person.unions then return nil end&lt;br /&gt;
	for _, union in ipairs(person.unions) do&lt;br /&gt;
		if union.partner == name2 then&lt;br /&gt;
			return union&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findChildLinkBetween(people, parentName, childName)&lt;br /&gt;
	if not isRealValue(parentName) or not isRealValue(childName) then return nil end&lt;br /&gt;
	local parent = people[parentName]&lt;br /&gt;
	if not parent or not parent.childLinks then return nil end&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(parent.childLinks) do&lt;br /&gt;
		if link.child == childName then&lt;br /&gt;
			return link&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function formatUnionMeta(unionType, status, dateValue)&lt;br /&gt;
	local bits = {}&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(unionType) then&lt;br /&gt;
		table.insert(bits, unionType)&lt;br /&gt;
	elseif isRealValue(status) then&lt;br /&gt;
		table.insert(bits, status)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local y = extractYear(dateValue)&lt;br /&gt;
	if isRealValue(y) then&lt;br /&gt;
		table.insert(bits, y)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #bits == 0 then return nil end&lt;br /&gt;
	return table.concat(bits, ' • ')&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function describeEdge(edge)&lt;br /&gt;
	if not edge then return nil end&lt;br /&gt;
&lt;br /&gt;
	if edge.type == &amp;quot;parent&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adopted child of' end&lt;br /&gt;
		if rel:find('step') then return 'stepchild of' end&lt;br /&gt;
		return 'child of'&lt;br /&gt;
	elseif edge.type == &amp;quot;child&amp;quot; then&lt;br /&gt;
		local rel = mw.ustring.lower(trim(edge.relationshipType) or '')&lt;br /&gt;
		if rel:find('adopt') then return 'adoptive parent of' end&lt;br /&gt;
		if rel:find('step') then return 'stepparent of' end&lt;br /&gt;
		return 'parent of'&lt;br /&gt;
	elseif edge.type == &amp;quot;partner&amp;quot; then&lt;br /&gt;
		local unionType = mw.ustring.lower(trim(edge.unionType) or '')&lt;br /&gt;
		local status = mw.ustring.lower(trim(edge.status) or '')&lt;br /&gt;
&lt;br /&gt;
		if unionType == 'marriage' then&lt;br /&gt;
			if status == 'ended' then return 'former spouse of' end&lt;br /&gt;
			return 'spouse of'&lt;br /&gt;
		end&lt;br /&gt;
		if unionType == 'affair' then return 'had an affair with' end&lt;br /&gt;
		if unionType == 'liaison' then return 'liaison with' end&lt;br /&gt;
		if unionType == 'engagement' then return 'engaged to' end&lt;br /&gt;
&lt;br /&gt;
		if status == 'ended' then return 'former partner of' end&lt;br /&gt;
		return 'partner of'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return edge.type .. &amp;quot; of&amp;quot;&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getParents(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
	local parents = uniq(person.parents)&lt;br /&gt;
	sortNames(people, parents)&lt;br /&gt;
	return parents&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getGrandparents(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, gp in ipairs(parent.parents) do&lt;br /&gt;
				addUnique(out, gp)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getSiblings(people, root)&lt;br /&gt;
	local out, seen = {}, {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(person.parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			for _, childName in ipairs(parent.children) do&lt;br /&gt;
				if childName ~= root and not seen[childName] then&lt;br /&gt;
					seen[childName] = true&lt;br /&gt;
					table.insert(out, childName)&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getConnectedPeople(people, root)&lt;br /&gt;
	local out = {}&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return out end&lt;br /&gt;
&lt;br /&gt;
	for _, v in ipairs(person.parents) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.children) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(person.partners) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getSiblings(people, root)) do addUnique(out, v) end&lt;br /&gt;
	for _, v in ipairs(getGrandparents(people, root)) do addUnique(out, v) end&lt;br /&gt;
&lt;br /&gt;
	out = uniq(out)&lt;br /&gt;
	sortNames(people, out)&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
	local siblings = getSiblings(people, root)&lt;br /&gt;
	sortNames(people, siblings)&lt;br /&gt;
	return splitAroundCenter(siblings)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then return {} end&lt;br /&gt;
&lt;br /&gt;
	local groups = {}&lt;br /&gt;
&lt;br /&gt;
	for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
		local key&lt;br /&gt;
		if isRealValue(link.unionID) then&lt;br /&gt;
			key = 'union::' .. link.unionID&lt;br /&gt;
		elseif isRealValue(link.otherParent) then&lt;br /&gt;
			key = 'partner::' .. link.otherParent&lt;br /&gt;
		else&lt;br /&gt;
			key = 'single::' .. root&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if not groups[key] then&lt;br /&gt;
			local union = nil&lt;br /&gt;
			if isRealValue(link.otherParent) then&lt;br /&gt;
				union = findUnionBetween(people, root, link.otherParent)&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			groups[key] = {&lt;br /&gt;
				key = key,&lt;br /&gt;
				unionID = link.unionID,&lt;br /&gt;
				partner = link.otherParent,&lt;br /&gt;
				children = {},&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
				sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
			}&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.insert(groups[key].children, {&lt;br /&gt;
			name = link.child,&lt;br /&gt;
			relationshipType = link.relationshipType,&lt;br /&gt;
			birthOrder = tonumber(link.birthOrder) or 999&lt;br /&gt;
		})&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
		if isRealValue(partner) then&lt;br /&gt;
			local found = false&lt;br /&gt;
			for _, group in pairs(groups) do&lt;br /&gt;
				if group.partner == partner then&lt;br /&gt;
					found = true&lt;br /&gt;
					break&lt;br /&gt;
				end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			if not found then&lt;br /&gt;
				local union = findUnionBetween(people, root, partner)&lt;br /&gt;
				local key = (union and union.unionID and ('union::' .. union.unionID)) or ('partner::' .. partner)&lt;br /&gt;
&lt;br /&gt;
				groups[key] = {&lt;br /&gt;
					key = key,&lt;br /&gt;
					unionID = union and union.unionID or nil,&lt;br /&gt;
					partner = partner,&lt;br /&gt;
					children = {},&lt;br /&gt;
					unionType = union and union.unionType or nil,&lt;br /&gt;
					status = union and union.status or nil,&lt;br /&gt;
					dateValue = union and (union.marriageDate or union.startDate or union.engagementDate) or nil,&lt;br /&gt;
					sortDate = union and sortKeyDate(union) or '9999-99-99'&lt;br /&gt;
				}&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
	for _, group in pairs(groups) do&lt;br /&gt;
		table.sort(group.children, function(a, b)&lt;br /&gt;
			if (a.birthOrder or 999) == (b.birthOrder or 999) then&lt;br /&gt;
				local ad = (people[a.name] and people[a.name].displayName) or a.name&lt;br /&gt;
				local bd = (people[b.name] and people[b.name].displayName) or b.name&lt;br /&gt;
				return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
			end&lt;br /&gt;
			return (a.birthOrder or 999) &amp;lt; (b.birthOrder or 999)&lt;br /&gt;
		end)&lt;br /&gt;
		table.insert(out, group)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(out, function(a, b)&lt;br /&gt;
		local aSingle = not isRealValue(a.partner)&lt;br /&gt;
		local bSingle = not isRealValue(b.partner)&lt;br /&gt;
&lt;br /&gt;
		if aSingle ~= bSingle then&lt;br /&gt;
			return aSingle&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		local ap = a.partner or ''&lt;br /&gt;
		local bp = b.partner or ''&lt;br /&gt;
		local ad = (people[ap] and people[ap].displayName) or ap&lt;br /&gt;
		local bd = (people[bp] and people[bp].displayName) or bp&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return out&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function choosePrimaryPartner(people, root, groups)&lt;br /&gt;
	local candidates = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			local score = 0&lt;br /&gt;
			local union = findUnionBetween(people, root, group.partner)&lt;br /&gt;
&lt;br /&gt;
			if union then&lt;br /&gt;
				local status = mw.ustring.lower(trim(union.status) or '')&lt;br /&gt;
				local utype = mw.ustring.lower(trim(union.unionType) or '')&lt;br /&gt;
&lt;br /&gt;
				if status == 'active' then score = score + 100 end&lt;br /&gt;
				if utype == 'marriage' then score = score + 50 end&lt;br /&gt;
				if utype == 'engagement' then score = score + 40 end&lt;br /&gt;
				if isRealValue(union.marriageDate) then score = score + 20 end&lt;br /&gt;
				if isRealValue(union.startDate) then score = score + 10 end&lt;br /&gt;
			end&lt;br /&gt;
&lt;br /&gt;
			table.insert(candidates, {&lt;br /&gt;
				partner = group.partner,&lt;br /&gt;
				score = score,&lt;br /&gt;
				sortDate = group.sortDate or '9999-99-99'&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	table.sort(candidates, function(a, b)&lt;br /&gt;
		if a.score ~= b.score then&lt;br /&gt;
			return a.score &amp;gt; b.score&lt;br /&gt;
		end&lt;br /&gt;
		if a.sortDate ~= b.sortDate then&lt;br /&gt;
			return a.sortDate &amp;lt; b.sortDate&lt;br /&gt;
		end&lt;br /&gt;
		local ad = (people[a.partner] and people[a.partner].displayName) or a.partner&lt;br /&gt;
		local bd = (people[b.partner] and people[b.partner].displayName) or b.partner&lt;br /&gt;
		return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
	end)&lt;br /&gt;
&lt;br /&gt;
	return candidates[1] and candidates[1].partner or nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Graph builder + path finder&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildGraph(people)&lt;br /&gt;
	local graph = {}&lt;br /&gt;
&lt;br /&gt;
	for name, _ in pairs(people) do&lt;br /&gt;
		graph[name] = {}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	for parentName, person in pairs(people) do&lt;br /&gt;
		-- Build parent/child edges from childLinks so relationshipType is preserved&lt;br /&gt;
		for _, link in ipairs(person.childLinks or {}) do&lt;br /&gt;
			local childName = trim(link.child)&lt;br /&gt;
&lt;br /&gt;
			if isRealValue(childName) and graph[parentName] and graph[childName] then&lt;br /&gt;
				table.insert(graph[parentName], {&lt;br /&gt;
					type = &amp;quot;child&amp;quot;,&lt;br /&gt;
					target = childName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				table.insert(graph[childName], {&lt;br /&gt;
					type = &amp;quot;parent&amp;quot;,&lt;br /&gt;
					target = parentName,&lt;br /&gt;
					relationshipType = link.relationshipType,&lt;br /&gt;
					unionID = link.unionID&lt;br /&gt;
				})&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		-- Partner edges&lt;br /&gt;
		for _, partner in ipairs(person.partners or {}) do&lt;br /&gt;
			local union = findUnionBetween(people, parentName, partner)&lt;br /&gt;
&lt;br /&gt;
			table.insert(graph[parentName], {&lt;br /&gt;
				type = &amp;quot;partner&amp;quot;,&lt;br /&gt;
				target = partner,&lt;br /&gt;
				unionType = union and union.unionType or nil,&lt;br /&gt;
				status = union and union.status or nil,&lt;br /&gt;
				unionID = union and union.unionID or nil&lt;br /&gt;
			})&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return graph&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function clonePath(path)&lt;br /&gt;
	local newPath = {}&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		newPath[i] = {&lt;br /&gt;
			name = step.name,&lt;br /&gt;
			via = step.via&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
	return newPath&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function findPath(graph, start, goal)&lt;br /&gt;
	if start == goal then&lt;br /&gt;
		return {&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local queue = {&lt;br /&gt;
		{&lt;br /&gt;
			{ name = start, via = nil }&lt;br /&gt;
		}&lt;br /&gt;
	}&lt;br /&gt;
&lt;br /&gt;
	local visited = {}&lt;br /&gt;
	visited[start] = true&lt;br /&gt;
&lt;br /&gt;
	while #queue &amp;gt; 0 do&lt;br /&gt;
		local path = table.remove(queue, 1)&lt;br /&gt;
		local current = path[#path].name&lt;br /&gt;
&lt;br /&gt;
		for _, edge in ipairs(graph[current] or {}) do&lt;br /&gt;
			local nextNode = edge.target&lt;br /&gt;
&lt;br /&gt;
			if not visited[nextNode] then&lt;br /&gt;
				local newPath = clonePath(path)&lt;br /&gt;
&lt;br /&gt;
				table.insert(newPath, {&lt;br /&gt;
					name = nextNode,&lt;br /&gt;
					via = edge&lt;br /&gt;
				})&lt;br /&gt;
&lt;br /&gt;
				if nextNode == goal then&lt;br /&gt;
					return newPath&lt;br /&gt;
				end&lt;br /&gt;
&lt;br /&gt;
				visited[nextNode] = true&lt;br /&gt;
				table.insert(queue, newPath)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return nil&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Rendering helpers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function buildFocalLayout(people, root, groups)&lt;br /&gt;
	local leftSibs, rightSibs = getOrderedSiblingsAroundRoot(people, root)&lt;br /&gt;
&lt;br /&gt;
	local soloGroup = nil&lt;br /&gt;
	local partnerGroups = {}&lt;br /&gt;
	local partners = {}&lt;br /&gt;
&lt;br /&gt;
	for _, group in ipairs(groups or {}) do&lt;br /&gt;
		if isRealValue(group.partner) then&lt;br /&gt;
			partnerGroups[group.partner] = group&lt;br /&gt;
			table.insert(partners, group.partner)&lt;br /&gt;
		else&lt;br /&gt;
			soloGroup = group&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	local unitIndex = {}&lt;br /&gt;
	local primaryPartner = choosePrimaryPartner(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local function addUnit(kind, name)&lt;br /&gt;
		if not isRealValue(name) then return end&lt;br /&gt;
		table.insert(units, { kind = kind, name = name })&lt;br /&gt;
		unitIndex[name] = #units&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if #leftSibs &amp;gt; 0 or #rightSibs &amp;gt; 0 then&lt;br /&gt;
		for _, sib in ipairs(leftSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, sib in ipairs(rightSibs) do&lt;br /&gt;
			addUnit('sibling', sib)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				addUnit('partner', partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		local others = {}&lt;br /&gt;
		for _, partner in ipairs(partners) do&lt;br /&gt;
			if partner ~= primaryPartner then&lt;br /&gt;
				table.insert(others, partner)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		table.sort(others, function(a, b)&lt;br /&gt;
			local ga = partnerGroups[a]&lt;br /&gt;
			local gb = partnerGroups[b]&lt;br /&gt;
			local da = ga and ga.sortDate or '9999-99-99'&lt;br /&gt;
			local db = gb and gb.sortDate or '9999-99-99'&lt;br /&gt;
			if da ~= db then&lt;br /&gt;
				return da &amp;lt; db&lt;br /&gt;
			end&lt;br /&gt;
			local ad = (people[a] and people[a].displayName) or a&lt;br /&gt;
			local bd = (people[b] and people[b].displayName) or b&lt;br /&gt;
			return mw.ustring.lower(ad) &amp;lt; mw.ustring.lower(bd)&lt;br /&gt;
		end)&lt;br /&gt;
&lt;br /&gt;
		local leftPartners, rightPartners = splitAroundCenter(others)&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(leftPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		addUnit('root', root)&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(primaryPartner) then&lt;br /&gt;
			addUnit('partner', primaryPartner)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		for _, partner in ipairs(rightPartners) do&lt;br /&gt;
			addUnit('partner', partner)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return {&lt;br /&gt;
		units = units,&lt;br /&gt;
		unitIndex = unitIndex,&lt;br /&gt;
		partnerGroups = partnerGroups,&lt;br /&gt;
		soloGroup = soloGroup,&lt;br /&gt;
		primaryPartner = primaryPartner&lt;br /&gt;
	}&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCard(people, name, badgeText, extraClass)&lt;br /&gt;
	if not isRealValue(name) then return nil end&lt;br /&gt;
	local person = people[name] or { name = name, displayName = name }&lt;br /&gt;
&lt;br /&gt;
	local card = html.create('div')&lt;br /&gt;
	card:addClass('kbft-card')&lt;br /&gt;
	if isRealValue(extraClass) then&lt;br /&gt;
		card:addClass(extraClass)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	card:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(badgeText) then&lt;br /&gt;
		card:tag('div')&lt;br /&gt;
			:addClass('kbft-years')&lt;br /&gt;
			:wikitext(badgeText)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return card&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderSingleCard(people, name, extraClass)&lt;br /&gt;
	local wrap = html.create('div')&lt;br /&gt;
	wrap:addClass('kbft-single')&lt;br /&gt;
	wrap:node(renderCard(people, name, nil, extraClass))&lt;br /&gt;
	return wrap&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderCouple(people, leftName, rightName)&lt;br /&gt;
	if not isRealValue(leftName) and not isRealValue(rightName) then&lt;br /&gt;
		return nil&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) and isRealValue(rightName) then&lt;br /&gt;
		local wrap = html.create('div')&lt;br /&gt;
		wrap:addClass('kbft-couple')&lt;br /&gt;
		wrap:node(renderCard(people, leftName))&lt;br /&gt;
		local marriage = wrap:tag('div')&lt;br /&gt;
		marriage:addClass('kbft-marriage')&lt;br /&gt;
		marriage:tag('div'):addClass('kbft-marriage-line')&lt;br /&gt;
		wrap:node(renderCard(people, rightName))&lt;br /&gt;
		return wrap&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if isRealValue(leftName) then return renderSingleCard(people, leftName) end&lt;br /&gt;
	return renderSingleCard(people, rightName)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderGenerationRow(units, className)&lt;br /&gt;
	local row = html.create('div')&lt;br /&gt;
	row:addClass(className or 'kbft-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(units) do&lt;br /&gt;
		if unit then row:node(unit) end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return row&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderUpperCoupleGeneration(people, couples)&lt;br /&gt;
	if #couples == 0 then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, pair in ipairs(couples) do&lt;br /&gt;
		table.insert(units, renderCouple(people, pair[1], pair[2]))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildGrandparentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	local couples = {}&lt;br /&gt;
&lt;br /&gt;
	for _, parentName in ipairs(parents) do&lt;br /&gt;
		local parent = people[parentName]&lt;br /&gt;
		if parent then&lt;br /&gt;
			local gp = uniq(parent.parents)&lt;br /&gt;
			sortNames(people, gp)&lt;br /&gt;
			if #gp &amp;gt; 0 then&lt;br /&gt;
				table.insert(couples, { gp[1], gp[2] })&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return couples&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function buildParentCouples(people, root)&lt;br /&gt;
	local parents = getParents(people, root)&lt;br /&gt;
	if #parents == 0 then return {} end&lt;br /&gt;
	return { { parents[1], parents[2] } }&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderFocalGeneration(people, layout)&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-focal-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-focal-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local col = row:tag('div')&lt;br /&gt;
		col:addClass('kbft-focal-col')&lt;br /&gt;
		col:attr('data-kind', unit.kind)&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name, 'kbft-focus-card'))&lt;br /&gt;
		else&lt;br /&gt;
			col:node(renderSingleCard(people, unit.name))&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderBranchColumn(people, group, isRootBranch)&lt;br /&gt;
	local col = html.create('div')&lt;br /&gt;
	col:addClass('kbft-branch-col')&lt;br /&gt;
&lt;br /&gt;
	if group then&lt;br /&gt;
		local meta = nil&lt;br /&gt;
&lt;br /&gt;
		if isRootBranch then&lt;br /&gt;
			local rel = nil&lt;br /&gt;
			if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
				rel = relationshipBadge(group.children[1].relationshipType)&lt;br /&gt;
			end&lt;br /&gt;
			meta = rel&lt;br /&gt;
		else&lt;br /&gt;
			meta = formatUnionMeta(group.unionType, group.status, group.dateValue)&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if isRealValue(meta) then&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta')&lt;br /&gt;
				:wikitext(meta)&lt;br /&gt;
		else&lt;br /&gt;
			col:tag('div')&lt;br /&gt;
				:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
				:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		if group.children and #group.children &amp;gt; 0 then&lt;br /&gt;
			col:tag('div'):addClass('kbft-child-down')&lt;br /&gt;
&lt;br /&gt;
			local childrenWrap = col:tag('div')&lt;br /&gt;
			childrenWrap:addClass('kbft-children')&lt;br /&gt;
&lt;br /&gt;
			for _, child in ipairs(group.children) do&lt;br /&gt;
				childrenWrap:node(&lt;br /&gt;
					renderCard(&lt;br /&gt;
						people,&lt;br /&gt;
						child.name,&lt;br /&gt;
						relationshipBadge(child.relationshipType)&lt;br /&gt;
					)&lt;br /&gt;
				)&lt;br /&gt;
			end&lt;br /&gt;
		end&lt;br /&gt;
	else&lt;br /&gt;
		col:tag('div')&lt;br /&gt;
			:addClass('kbft-union-meta kbft-union-meta-empty')&lt;br /&gt;
			:wikitext('&amp;amp;nbsp;')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return col&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderDescendantGeneration(people, layout)&lt;br /&gt;
	local hasAnything = false&lt;br /&gt;
	if layout.soloGroup then&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
	end&lt;br /&gt;
	for _, _ in pairs(layout.partnerGroups or {}) do&lt;br /&gt;
		hasAnything = true&lt;br /&gt;
		break&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	if not hasAnything then return nil end&lt;br /&gt;
&lt;br /&gt;
	local gen = html.create('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
	gen:addClass('kbft-desc-generation')&lt;br /&gt;
&lt;br /&gt;
	local row = gen:tag('div')&lt;br /&gt;
	row:addClass('kbft-desc-row')&lt;br /&gt;
&lt;br /&gt;
	for _, unit in ipairs(layout.units) do&lt;br /&gt;
		local group = nil&lt;br /&gt;
		local isRootBranch = false&lt;br /&gt;
&lt;br /&gt;
		if unit.kind == 'root' then&lt;br /&gt;
			group = layout.soloGroup&lt;br /&gt;
			isRootBranch = true&lt;br /&gt;
		elseif unit.kind == 'partner' then&lt;br /&gt;
			group = layout.partnerGroups[unit.name]&lt;br /&gt;
		end&lt;br /&gt;
&lt;br /&gt;
		row:node(renderBranchColumn(people, group, isRootBranch))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return gen&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public renderers&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
local function renderConnectedForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local connected = getConnectedPeople(people, root)&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Connected to ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gen = node:tag('div')&lt;br /&gt;
	gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
	local units = {}&lt;br /&gt;
	for _, name in ipairs(connected) do&lt;br /&gt;
		table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
	end&lt;br /&gt;
	gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderProfileForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext(makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local function addSection(label, names)&lt;br /&gt;
		names = uniq(names)&lt;br /&gt;
		if #names == 0 then return end&lt;br /&gt;
		sortNames(people, names)&lt;br /&gt;
&lt;br /&gt;
		node:tag('div')&lt;br /&gt;
			:addClass('kbft-title')&lt;br /&gt;
			:css('margin-top', '22px')&lt;br /&gt;
			:wikitext(label)&lt;br /&gt;
&lt;br /&gt;
		local gen = node:tag('div')&lt;br /&gt;
		gen:addClass('kbft-generation')&lt;br /&gt;
&lt;br /&gt;
		local units = {}&lt;br /&gt;
		for _, name in ipairs(names) do&lt;br /&gt;
			table.insert(units, renderSingleCard(people, name))&lt;br /&gt;
		end&lt;br /&gt;
		gen:node(renderGenerationRow(units, 'kbft-row'))&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	addSection('Parents', person.parents)&lt;br /&gt;
	addSection('Partners', person.partners)&lt;br /&gt;
	addSection('Children', person.children)&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
local function renderTreeForRoot(people, root)&lt;br /&gt;
	local person = people[root]&lt;br /&gt;
	if not person then&lt;br /&gt;
		return '&amp;lt;strong&amp;gt;FamilyTree error:&amp;lt;/strong&amp;gt; No character found for &amp;quot;' .. tostring(root) .. '&amp;quot;.'&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local groups = getFamilyGroupsForRoot(people, root)&lt;br /&gt;
	local layout = buildFocalLayout(people, root, groups)&lt;br /&gt;
&lt;br /&gt;
	local node = html.create('div')&lt;br /&gt;
	node:addClass('kbft-tree')&lt;br /&gt;
&lt;br /&gt;
	node:tag('div')&lt;br /&gt;
		:addClass('kbft-title')&lt;br /&gt;
		:wikitext('Family Tree: ' .. makeLink(person.name, person.displayName))&lt;br /&gt;
&lt;br /&gt;
	local gpGen = renderUpperCoupleGeneration(people, buildGrandparentCouples(people, root))&lt;br /&gt;
	if gpGen then&lt;br /&gt;
		node:node(gpGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local parentGen = renderUpperCoupleGeneration(people, buildParentCouples(people, root))&lt;br /&gt;
	if parentGen then&lt;br /&gt;
		node:node(parentGen)&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	node:node(renderFocalGeneration(people, layout))&lt;br /&gt;
&lt;br /&gt;
	local descGen = renderDescendantGeneration(people, layout)&lt;br /&gt;
	if descGen then&lt;br /&gt;
		node:tag('div'):addClass('kbft-connector')&lt;br /&gt;
		node:node(descGen)&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return tostring(node)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
-- =========================================&lt;br /&gt;
-- Public functions&lt;br /&gt;
-- =========================================&lt;br /&gt;
&lt;br /&gt;
function p.tree(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderTreeForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.profile(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderProfileForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.connected(frame)&lt;br /&gt;
	local root = getRoot(frame)&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	return renderConnectedForRoot(people, root)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
function p.path(frame)&lt;br /&gt;
	local from = getArg(frame, 'from')&lt;br /&gt;
	local to = getArg(frame, 'to')&lt;br /&gt;
&lt;br /&gt;
	if not isRealValue(from) or not isRealValue(to) then&lt;br /&gt;
		return &amp;quot;&amp;lt;strong&amp;gt;Error:&amp;lt;/strong&amp;gt; Please provide |from= and |to=&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local people = loadData()&lt;br /&gt;
	local graph = buildGraph(people)&lt;br /&gt;
	local path = findPath(graph, from, to)&lt;br /&gt;
&lt;br /&gt;
	if not path then&lt;br /&gt;
		return &amp;quot;No connection found.&amp;quot;&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	local out = {}&lt;br /&gt;
&lt;br /&gt;
	for i, step in ipairs(path) do&lt;br /&gt;
		local name = step.name&lt;br /&gt;
		local displayName = (people[name] and people[name].displayName) or name&lt;br /&gt;
		local linkedName = makeLink(name, displayName)&lt;br /&gt;
&lt;br /&gt;
		if i == 1 then&lt;br /&gt;
			table.insert(out, linkedName)&lt;br /&gt;
		else&lt;br /&gt;
			local label = describeEdge(step.via) or &amp;quot;connected to&amp;quot;&lt;br /&gt;
			table.insert(out, label .. &amp;quot; &amp;quot; .. linkedName)&lt;br /&gt;
		end&lt;br /&gt;
	end&lt;br /&gt;
&lt;br /&gt;
	return table.concat(out, &amp;quot; → &amp;quot;)&lt;br /&gt;
end&lt;br /&gt;
&lt;br /&gt;
return p&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Rosalie_Laurence&amp;diff=1931</id>
		<title>Rosalie Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Rosalie_Laurence&amp;diff=1931"/>
		<updated>2026-04-15T15:42:55Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Infobox character&lt;br /&gt;
| name             = Rosalie Gretchen McCormick&lt;br /&gt;
| image            = Rosalie Laurence.png&lt;br /&gt;
| gender           = Female&lt;br /&gt;
| nicknames        = Rosie, Dove&lt;br /&gt;
| born             = 7 August 1904&lt;br /&gt;
| family           = [[The Laurence Family]]        &lt;br /&gt;
[[The McCormick Family]]&lt;br /&gt;
| bloodtype        = Pureblood&lt;br /&gt;
| social_class     = Nobility&lt;br /&gt;
| house            = Gryffindor&lt;br /&gt;
| graduation_year  = Class of 1922&lt;br /&gt;
| occupation       = Student&lt;br /&gt;
| residence        = Arundel Castle, West Sussex, England&lt;br /&gt;
| wand             = &lt;br /&gt;
| patronus         = Black Mamba&lt;br /&gt;
| significant_other= [[Cassian McCormick]]&lt;br /&gt;
| friends          = &lt;br /&gt;
| status           = Alive&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
Rosalie is a member of the aristocratic [[The Laurence Family|Laurence Family]], based out of West Sussex who hold the duchy title of Norfolk. She is the only surviving child of her parents, Leo and Gretchen Laurence. Her father, Leo is cousin of [[James Laurence]], one of England’s most powerful and influential dukes. Rosie’s mother, Gretchen is a pureblood witch from an upper middle-class background.&lt;br /&gt;
&lt;br /&gt;
Her younger brother Cameron, died when he was eight months old due to an illness he was afflicted with at birth.&lt;br /&gt;
&lt;br /&gt;
The wider family includes her uncle Arthur and his two boys, Matthew and Thomas who are four and two years older than her, respectively, as well as her grandad and granny. She is the second cousin of [[Julia Laurence|Julia]] and James as well as [[Benji Laurence|Benji]], [[Kathryn Barlowe|Kate]], Adira and Claire and the wife of [[Cassian McCormick]].&lt;br /&gt;
&lt;br /&gt;
=== Personality ===&lt;br /&gt;
Rosalie is thoughtful, empathetic, kind, gentle, and far stronger than people usually give her credit for. She was raised to be composed and agreeable, so she learned early on to keep her emotions tidy and her voice quiet. This doesn’t mean she lacks conviction. &lt;br /&gt;
&lt;br /&gt;
She reads people easily, often carrying more of their pain than her own, and she has a bad habit of blaming herself for things she couldn’t possibly control. Her instinct is always to fix, soothe, or protect, sometimes even at her own expense. She is a nurturer, a caretaker and is often seen as the 'therapist' of her friend group.&lt;br /&gt;
&lt;br /&gt;
Rosalie hates conflict and will do anything to keep the peace, but when someone she loves is threatened, she becomes sharp and fearless in ways that surprise people. She’s intelligent, observant, and quietly stubborn, though her need to please others can make her seem passive. What defines her most is loyalty; once she commits to someone, she doesn’t waiver. Over time, she’s learning that love doesn’t have to mean self-sacrifice, and that her softness isn’t something to outgrow, it’s what makes her powerful.&lt;br /&gt;
&lt;br /&gt;
=== History ===&lt;br /&gt;
Rosalie Gretchen Laurence was born on August 7th, 1904, at St. Mungo’s Hospital, the only surviving child of Leo and Gretchen Laurence. Her father, a Ministry barrister, spent most of his time in London, while her mother managed the family’s apartments in London and at Arundel Castle, while overseeing Rosalie’s upbringing with the precision of a governess. As a member of the extended Laurence family - one of the most established pureblood lineages in England - Rosalie grew up surrounded by wealth, formality, and expectation. Though not a direct heir to the duchy, she enjoyed every privilege of her cousins’ social sphere: fine tutors, elaborate parties, and the constant reminder of her family’s legacy.&lt;br /&gt;
&lt;br /&gt;
Her parents’ protectiveness bordered on isolation. They chose to have her privately tutored instead of sending her to Beauxbatons, breaking from family tradition but ensuring control over every part of her life. Rosalie’s childhood was structured, quiet, and lonely. She filled her days with lessons, piano practice, and solitary walks around the estate gardens, while her rare moments of freedom came through sports at private clubs. Her parents discouraged friendships with Muggleborns or Muggles, and though Rosalie quietly rejected their prejudice, she learned early how to keep her opinions hidden behind polite smiles.&lt;br /&gt;
&lt;br /&gt;
When she was finally allowed to attend Hogwarts in her fourth year, it was the first real taste of freedom she’d ever known. Away from the suffocating walls of her family’s estate, Rosalie began to find herself. The castle’s vastness, the chaos of other students, and the unfiltered mix of backgrounds opened her eyes to a world much larger than the one she’d been raised in. Over her first two years, she blossomed; still reserved, but growing bolder in her choices. She made friends who challenged her worldview, discovered passions that weren’t pre-approved by her parents, and slowly began testing boundaries.&lt;br /&gt;
&lt;br /&gt;
That hunger for independence deepened when she met [[Cassian McCormick]] during her fourth year. What began as curiosity quickly grew into the kind of connection that defied every rule she’d been taught to live by. Cassian represented everything her family feared: lower status, outspoken, a dreamer with little regard for pedigree, and yet, he was the first person who made her feel seen for who she actually was. Their relationship became her rebellion, her education, and her awakening all at once.&lt;br /&gt;
&lt;br /&gt;
Through the years that followed, Rosalie’s life unraveled from the tidy perfection her parents had curated. The fallout from her family discovering her relationship with Cassian forced her into impossible choices, between love and loyalty, between freedom and safety. She ran away, fought to survive, and faced losses that hardened her in ways no tutor ever could.&lt;br /&gt;
&lt;br /&gt;
By the 1920s, Rosalie is no longer the sheltered girl from West Sussex. She’s a young woman shaped by defiance, heartbreak, and resilience, still caught between the privilege of her name and the person she’s become despite it. Rosalie’s story is about learning how to belong to herself after a lifetime of being told who to be.&lt;br /&gt;
&lt;br /&gt;
On August 8, 1921, right after their seventeenth birthdays, Cassian and Rosalie married in St. Alban's and immediately fled to France.&lt;br /&gt;
&lt;br /&gt;
[[Category:Gryffindor]] [[Category:Class of 1922]]&lt;br /&gt;
&lt;br /&gt;
{{DEFAULTSORT:Laurence, Rosalie}}&lt;br /&gt;
[[Category:Hogwarts Students]]&lt;br /&gt;
[[Category:Written by Amber]]&lt;br /&gt;
[[Category:Prefects]]&lt;br /&gt;
&lt;br /&gt;
{{Character Record&lt;br /&gt;
|DisplayName=Rosalie Laurence&lt;br /&gt;
|Gender=Female&lt;br /&gt;
|BirthDate=1904-08-07&lt;br /&gt;
|Status=Alive&lt;br /&gt;
|BirthFamily=The Laurence Family&lt;br /&gt;
|CurrentFamily=The McCormick Family&lt;br /&gt;
|BloodStatus=Pureblood&lt;br /&gt;
|Heir=No&lt;br /&gt;
|Illegitimate=No&lt;br /&gt;
|Adopted=No&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1930</id>
		<title>Benji Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1930"/>
		<updated>2026-04-15T15:40:22Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Infobox character&lt;br /&gt;
| name             = Benji Henry Laurence&lt;br /&gt;
| image            = Benji 5th Year.png &lt;br /&gt;
| gender           = Male&lt;br /&gt;
| nicknames        = Ben, Benny&lt;br /&gt;
| born             = 04 April 1906&lt;br /&gt;
| died             = &lt;br /&gt;
| family           = [[The Laurence Family]]&lt;br /&gt;
| bloodtype        = Pureblood&lt;br /&gt;
| social_class     = Nobility&lt;br /&gt;
| house            = Hufflepuff&lt;br /&gt;
| graduation_year  = Class of 1924&lt;br /&gt;
| occupation       = Student&lt;br /&gt;
| residence        = Arundel Castle, West Sussex, England&lt;br /&gt;
| wand             = Spruce Wood, Billywig Stinger, 12 1/2 inches, Unbending&lt;br /&gt;
| patronus         = Raccoon&lt;br /&gt;
| parents          = [[Julia Laurence]], [[Maddox Barlowe]]&lt;br /&gt;
| siblings         = [[Kathryn Barlowe]], Jude Barlowe, Evander Barlowe, [[Morgan Barlowe]]&lt;br /&gt;
| significant_other= [[Ruth Elliot]]&lt;br /&gt;
| children         = &lt;br /&gt;
| friends          = [[Matilda Nordstrom]], [[Alice Ravenstone]], [[Cassian McCormick]], [[Rosalie Laurence]], [[Evander Whistler]], [[Ren Al-Sayeed]], [[Oilibhéar Ó Coigligh]]&lt;br /&gt;
| status           = Alive&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
Benji Laurence (formerly Cuddrun) is a member of the aristocratic [[The Laurence Family|Laurence Family]], based out of West Sussex who hold the duchy title of Norfolk. Adopted in 1920, along with his sister [[Kathryn Barlowe]], he is the eldest son of [[Julia Laurence|Julia]] and current heir to the family ducal seat, currently held by his uncle, [[James Laurence]].&lt;br /&gt;
&lt;br /&gt;
The wider family includes his aunt Edith (James and Julia's widowed sister-in-law) and her daughter Adira, James's wife Amelia and their daughter Claire. Beyond that he has several second cousins - including , [[Rosalie Laurence|Rosalie]], Thomas and Matthew Laurence and their parents and grandparents.&lt;br /&gt;
&lt;br /&gt;
=== Personality ===&lt;br /&gt;
Benji is the type of kid you think you already know at first glance. Smirking while he hits you with one-liner after one-liner. Quick to pull pranks, quick to tell big stories to get a rise out of you. Always in trouble, always looking for the next thing he can scramble.&lt;br /&gt;
&lt;br /&gt;
But beneath the veneer of a troublemaker, Benji is incredibly introspective with a heart of gold. He cares deeply for the people who show him any kind of love or loyalty and offers his friendship sometimes a little too easily.&lt;br /&gt;
&lt;br /&gt;
He's impulsive, quick-thinking and hot-headed. But at the core of it all, Benji is just someone who wants to feel a sense of belonging, family and home.&lt;br /&gt;
&lt;br /&gt;
=== History ===&lt;br /&gt;
There wasn’t much the world expected of street kids. Benji Cuddrun may as well have been born in the gutters of Richmond Road, the way London society saw children – especially boys - like him.&lt;br /&gt;
&lt;br /&gt;
Benji’s parents, Harry and Liza Cuddrun were generally apathetic about their wayward boy. Harry, a pureblood and war veteran, ran a wizard pub a few streets from their home. He worked long hours, and when he was home, he often sat in silence, reading his books and pretending his wife and children didn’t exist. He had been much like his son, a wayward young man with not much promise or much to offer the world, and despite his military service (or perhaps because of) that hadn’t changed with age.&lt;br /&gt;
&lt;br /&gt;
Liza, a pureblood witch had come from a decent, middle-class family that encouraged hard work and the importance of family. Much to her family’s dismay and confusion, she had gotten involved with Harry, and all that had flown out the proverbial window. She spent much of her time staring at the tele and not much else. Occasionally, she would muster the strength to shout at her two children when she couldn’t hear her programs. If they stayed out of her hair and didn’t ask her for much, or bring complaining neighbors to her door, she really didn’t care what they did. When they did cross her, things became extremely violent for both Benji and Kate.&lt;br /&gt;
&lt;br /&gt;
Raised in the poor Hackney borough of London, Benji rarely stayed home and even more rarely attended school as he should. At the young age of only eleven, Benji seemed as street-wise as the older teenaged boys he ran around with most days. They hung around on door-stoops, shouting rude words at passersby, or riding their skateboards around the neighboring boroughs. They knocked over bins, graffitied random back-alley buildings, smoked cigarettes and generally caused a nuisance wherever they went.&lt;br /&gt;
&lt;br /&gt;
Benji loved every moment of it. It was an escape from the monotonous, lackluster and cold environment of his own home. The other boys gave him a sense of belonging and family, in a way his own never did. He felt cared for when one of the older kids would steal him a fizzy from the corner store, or when they’d give him a pair of their old shoes, knowing his parents would never buy him new ones. It was natural to him that he’d do everything in his power to impress them, or do whatever they asked him to. The fact that strange, almost magical things happened around him occasionally only made him more well-liked amongst them.&lt;br /&gt;
&lt;br /&gt;
The only thing Benji loved more than being a part of his band of misfit toys, was his little six-year-old sister Kate. While he would never admit it to his friends, Benji took special efforts to ensure Kate was cared for. He got up every day and made her breakfast from what little food they had, helped her comb her hair and walked her to school. If she really pleaded, he’d stay and attend school as he should have. He always walked her home, got her a snack and made sure she was safely entertained in her bedroom, before he’d run out the front door again.&lt;br /&gt;
&lt;br /&gt;
His first two years at Hogwarts centered around Benji really trying to understand his trauma, how it had affected him and the people around him. He fell into the wrong crowd for awhile, trying to find something that felt like the friends he had back home. He struggled hard with his alcohol addiction he'd been nursing since he was nine - a habit he'd picked up from the older boys to stop the pain after his mother's beatings.&lt;br /&gt;
&lt;br /&gt;
During this time, Benji and his sister were both removed from their parents' custody and placed in an orphanage for magical children.&lt;br /&gt;
&lt;br /&gt;
In his third year, Benji got clean, under the help of his future adoptive mother, Julia and reconciled with the original friends he'd made in first year. He spent all of third year getting to know who really was, what he liked, what he didn't and the kind of friend he wanted to be to people. Over the summer, he and his sister were both adopted into the Laurence family - one of the most powerful Pureblood aristocratic families in Britain, and he is the current heir to the estate and ducal seat.&lt;br /&gt;
 &lt;br /&gt;
[[Category:Hufflepuff]]&lt;br /&gt;
[[Category:Class of 1924]]&lt;br /&gt;
{{DEFAULTSORT:Laurence, Benji}}&lt;br /&gt;
[[Category:Hogwarts Students]]&lt;br /&gt;
[[Category:Written by Amber]]&lt;br /&gt;
[[Category:Prefects]]&lt;br /&gt;
&lt;br /&gt;
{{Character Record&lt;br /&gt;
|DisplayName=Benji Laurence&lt;br /&gt;
|Gender=Male&lt;br /&gt;
|BirthDate=1904-04-04&lt;br /&gt;
|Status=Alive&lt;br /&gt;
|BirthFamily=The Cuddrun Family&lt;br /&gt;
|CurrentFamily=The Laurence Family&lt;br /&gt;
|AdoptiveMother=Julia Laurence&lt;br /&gt;
|BloodStatus=Pureblood&lt;br /&gt;
|Heir=Yes&lt;br /&gt;
|Illegitimate=No&lt;br /&gt;
|Adopted=Yes&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1929</id>
		<title>Benji Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1929"/>
		<updated>2026-04-15T15:40:05Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Infobox character&lt;br /&gt;
| name             = Benji Henry Laurence&lt;br /&gt;
| image            = Benji 5th Year.png &lt;br /&gt;
| gender           = Male&lt;br /&gt;
| nicknames        = Ben, Benny&lt;br /&gt;
| born             = 04 April 1906&lt;br /&gt;
| died             = &lt;br /&gt;
| family           = [[The Laurence Family]]&lt;br /&gt;
| bloodtype        = Pureblood&lt;br /&gt;
| social_class     = Nobility&lt;br /&gt;
| house            = Hufflepuff&lt;br /&gt;
| graduation_year  = Class of 1924&lt;br /&gt;
| occupation       = Student&lt;br /&gt;
| residence        = Arundel Castle, West Sussex, England&lt;br /&gt;
| wand             = Spruce Wood, Billywig Stinger, 12 1/2 inches, Unbending&lt;br /&gt;
| patronus         = Raccoon&lt;br /&gt;
| parents          = [[Julia Laurence]], [[Maddox Barlowe]]&lt;br /&gt;
| siblings         = [[Kathryn Barlowe]], Jude Barlowe, Evander Barlowe, [[Morgan Barlowe]]&lt;br /&gt;
| significant_other= [[Ruth Elliot]]&lt;br /&gt;
| children         = &lt;br /&gt;
| friends          = [[Matilda Nordstrom]], [[Alice Ravenstone]], [[Cassian McCormick]], [[Rosalie Laurence]], [[Evander Whistler]], [[Ren Al-Sayeed]], [[Oilibhéar Ó Coigligh]]&lt;br /&gt;
| status           = Alive&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
Benji Laurence (formerly Cuddrun) is a member of the aristocratic [[The Laurence Family|Laurence Family]], based out of West Sussex who hold the duchy title of Norfolk. Adopted in 1920, along with his sister [[Kathryn Barlowe]], he is the only son of [[Julia Laurence|Julia]] and current heir to the family ducal seat, currently held by his uncle, [[James Laurence]].&lt;br /&gt;
&lt;br /&gt;
The wider family includes his aunt Edith (James and Julia's widowed sister-in-law) and her daughter Adira, James's wife Amelia and their daughter Claire. Beyond that he has several second cousins - including , [[Rosalie Laurence|Rosalie]], Thomas and Matthew Laurence and their parents and grandparents.&lt;br /&gt;
&lt;br /&gt;
=== Personality ===&lt;br /&gt;
Benji is the type of kid you think you already know at first glance. Smirking while he hits you with one-liner after one-liner. Quick to pull pranks, quick to tell big stories to get a rise out of you. Always in trouble, always looking for the next thing he can scramble.&lt;br /&gt;
&lt;br /&gt;
But beneath the veneer of a troublemaker, Benji is incredibly introspective with a heart of gold. He cares deeply for the people who show him any kind of love or loyalty and offers his friendship sometimes a little too easily.&lt;br /&gt;
&lt;br /&gt;
He's impulsive, quick-thinking and hot-headed. But at the core of it all, Benji is just someone who wants to feel a sense of belonging, family and home.&lt;br /&gt;
&lt;br /&gt;
=== History ===&lt;br /&gt;
There wasn’t much the world expected of street kids. Benji Cuddrun may as well have been born in the gutters of Richmond Road, the way London society saw children – especially boys - like him.&lt;br /&gt;
&lt;br /&gt;
Benji’s parents, Harry and Liza Cuddrun were generally apathetic about their wayward boy. Harry, a pureblood and war veteran, ran a wizard pub a few streets from their home. He worked long hours, and when he was home, he often sat in silence, reading his books and pretending his wife and children didn’t exist. He had been much like his son, a wayward young man with not much promise or much to offer the world, and despite his military service (or perhaps because of) that hadn’t changed with age.&lt;br /&gt;
&lt;br /&gt;
Liza, a pureblood witch had come from a decent, middle-class family that encouraged hard work and the importance of family. Much to her family’s dismay and confusion, she had gotten involved with Harry, and all that had flown out the proverbial window. She spent much of her time staring at the tele and not much else. Occasionally, she would muster the strength to shout at her two children when she couldn’t hear her programs. If they stayed out of her hair and didn’t ask her for much, or bring complaining neighbors to her door, she really didn’t care what they did. When they did cross her, things became extremely violent for both Benji and Kate.&lt;br /&gt;
&lt;br /&gt;
Raised in the poor Hackney borough of London, Benji rarely stayed home and even more rarely attended school as he should. At the young age of only eleven, Benji seemed as street-wise as the older teenaged boys he ran around with most days. They hung around on door-stoops, shouting rude words at passersby, or riding their skateboards around the neighboring boroughs. They knocked over bins, graffitied random back-alley buildings, smoked cigarettes and generally caused a nuisance wherever they went.&lt;br /&gt;
&lt;br /&gt;
Benji loved every moment of it. It was an escape from the monotonous, lackluster and cold environment of his own home. The other boys gave him a sense of belonging and family, in a way his own never did. He felt cared for when one of the older kids would steal him a fizzy from the corner store, or when they’d give him a pair of their old shoes, knowing his parents would never buy him new ones. It was natural to him that he’d do everything in his power to impress them, or do whatever they asked him to. The fact that strange, almost magical things happened around him occasionally only made him more well-liked amongst them.&lt;br /&gt;
&lt;br /&gt;
The only thing Benji loved more than being a part of his band of misfit toys, was his little six-year-old sister Kate. While he would never admit it to his friends, Benji took special efforts to ensure Kate was cared for. He got up every day and made her breakfast from what little food they had, helped her comb her hair and walked her to school. If she really pleaded, he’d stay and attend school as he should have. He always walked her home, got her a snack and made sure she was safely entertained in her bedroom, before he’d run out the front door again.&lt;br /&gt;
&lt;br /&gt;
His first two years at Hogwarts centered around Benji really trying to understand his trauma, how it had affected him and the people around him. He fell into the wrong crowd for awhile, trying to find something that felt like the friends he had back home. He struggled hard with his alcohol addiction he'd been nursing since he was nine - a habit he'd picked up from the older boys to stop the pain after his mother's beatings.&lt;br /&gt;
&lt;br /&gt;
During this time, Benji and his sister were both removed from their parents' custody and placed in an orphanage for magical children.&lt;br /&gt;
&lt;br /&gt;
In his third year, Benji got clean, under the help of his future adoptive mother, Julia and reconciled with the original friends he'd made in first year. He spent all of third year getting to know who really was, what he liked, what he didn't and the kind of friend he wanted to be to people. Over the summer, he and his sister were both adopted into the Laurence family - one of the most powerful Pureblood aristocratic families in Britain, and he is the current heir to the estate and ducal seat.&lt;br /&gt;
 &lt;br /&gt;
[[Category:Hufflepuff]]&lt;br /&gt;
[[Category:Class of 1924]]&lt;br /&gt;
{{DEFAULTSORT:Laurence, Benji}}&lt;br /&gt;
[[Category:Hogwarts Students]]&lt;br /&gt;
[[Category:Written by Amber]]&lt;br /&gt;
[[Category:Prefects]]&lt;br /&gt;
&lt;br /&gt;
{{Character Record&lt;br /&gt;
|DisplayName=Benji Laurence&lt;br /&gt;
|Gender=Male&lt;br /&gt;
|BirthDate=1904-04-04&lt;br /&gt;
|Status=Alive&lt;br /&gt;
|BirthFamily=The Cuddrun Family&lt;br /&gt;
|CurrentFamily=The Laurence Family&lt;br /&gt;
|AdoptiveMother=Julia Laurence&lt;br /&gt;
|BloodStatus=Pureblood&lt;br /&gt;
|Heir=Yes&lt;br /&gt;
|Illegitimate=No&lt;br /&gt;
|Adopted=Yes&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1928</id>
		<title>Benji Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Benji_Laurence&amp;diff=1928"/>
		<updated>2026-04-15T15:39:49Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Infobox character&lt;br /&gt;
| name             = Benji Henry Laurence&lt;br /&gt;
| image            = Benji 5th Year.png &lt;br /&gt;
| gender           = Male&lt;br /&gt;
| nicknames        = Ben, Benny&lt;br /&gt;
| born             = 04 April 1906&lt;br /&gt;
| died             = &lt;br /&gt;
| family           = [[The Laurence Family]]&lt;br /&gt;
| bloodtype        = Pureblood&lt;br /&gt;
| social_class     = Nobility&lt;br /&gt;
| house            = Hufflepuff&lt;br /&gt;
| graduation_year  = Class of 1924&lt;br /&gt;
| occupation       = Student&lt;br /&gt;
| residence        = Arundel Castle, West Sussex, England&lt;br /&gt;
| wand             = Spruce Wood, Billywig Stinger, 12 1/2 inches, Unbending&lt;br /&gt;
| patronus         = Raccoon&lt;br /&gt;
| parents          = [[Julia Laurence]], [[Maddox Barlowe]]&lt;br /&gt;
| siblings         = [[Kathryn Barlowe]], Jude Barlowe, Evander Barlowe, [[Morgan Barlowe]]&lt;br /&gt;
| significant_other= [[Ruth Elliot]]&lt;br /&gt;
| children         = &lt;br /&gt;
| friends          = [[Matilda Nordstrom]], [[Alice Ravenstone]], [[Cassian McCormick]], [[Rosalie Laurence]], [[Evander Whistler]], [[Ren Al-Sayeed]], [[Oilibhéar Ó Coigligh]]&lt;br /&gt;
| status           = Alive&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
Benji Laurence (formerly Cuddrun) is a member of the aristocratic [[The Laurence Family|Laurence Family]], based out of West Sussex who hold the duchy title of Norfolk. Adopted in 1920, along with his sister [[Kathryn Laurence]], he is the only son of [[Julia Laurence|Julia]] and current heir to the family ducal seat, currently held by his uncle, [[James Laurence]].&lt;br /&gt;
&lt;br /&gt;
The wider family includes his aunt Edith (James and Julia's widowed sister-in-law) and her daughter Adira, James's wife Amelia and their daughter Claire. Beyond that he has several second cousins - including , [[Rosalie Laurence|Rosalie]], Thomas and Matthew Laurence and their parents and grandparents.&lt;br /&gt;
&lt;br /&gt;
=== Personality ===&lt;br /&gt;
Benji is the type of kid you think you already know at first glance. Smirking while he hits you with one-liner after one-liner. Quick to pull pranks, quick to tell big stories to get a rise out of you. Always in trouble, always looking for the next thing he can scramble.&lt;br /&gt;
&lt;br /&gt;
But beneath the veneer of a troublemaker, Benji is incredibly introspective with a heart of gold. He cares deeply for the people who show him any kind of love or loyalty and offers his friendship sometimes a little too easily.&lt;br /&gt;
&lt;br /&gt;
He's impulsive, quick-thinking and hot-headed. But at the core of it all, Benji is just someone who wants to feel a sense of belonging, family and home.&lt;br /&gt;
&lt;br /&gt;
=== History ===&lt;br /&gt;
There wasn’t much the world expected of street kids. Benji Cuddrun may as well have been born in the gutters of Richmond Road, the way London society saw children – especially boys - like him.&lt;br /&gt;
&lt;br /&gt;
Benji’s parents, Harry and Liza Cuddrun were generally apathetic about their wayward boy. Harry, a pureblood and war veteran, ran a wizard pub a few streets from their home. He worked long hours, and when he was home, he often sat in silence, reading his books and pretending his wife and children didn’t exist. He had been much like his son, a wayward young man with not much promise or much to offer the world, and despite his military service (or perhaps because of) that hadn’t changed with age.&lt;br /&gt;
&lt;br /&gt;
Liza, a pureblood witch had come from a decent, middle-class family that encouraged hard work and the importance of family. Much to her family’s dismay and confusion, she had gotten involved with Harry, and all that had flown out the proverbial window. She spent much of her time staring at the tele and not much else. Occasionally, she would muster the strength to shout at her two children when she couldn’t hear her programs. If they stayed out of her hair and didn’t ask her for much, or bring complaining neighbors to her door, she really didn’t care what they did. When they did cross her, things became extremely violent for both Benji and Kate.&lt;br /&gt;
&lt;br /&gt;
Raised in the poor Hackney borough of London, Benji rarely stayed home and even more rarely attended school as he should. At the young age of only eleven, Benji seemed as street-wise as the older teenaged boys he ran around with most days. They hung around on door-stoops, shouting rude words at passersby, or riding their skateboards around the neighboring boroughs. They knocked over bins, graffitied random back-alley buildings, smoked cigarettes and generally caused a nuisance wherever they went.&lt;br /&gt;
&lt;br /&gt;
Benji loved every moment of it. It was an escape from the monotonous, lackluster and cold environment of his own home. The other boys gave him a sense of belonging and family, in a way his own never did. He felt cared for when one of the older kids would steal him a fizzy from the corner store, or when they’d give him a pair of their old shoes, knowing his parents would never buy him new ones. It was natural to him that he’d do everything in his power to impress them, or do whatever they asked him to. The fact that strange, almost magical things happened around him occasionally only made him more well-liked amongst them.&lt;br /&gt;
&lt;br /&gt;
The only thing Benji loved more than being a part of his band of misfit toys, was his little six-year-old sister Kate. While he would never admit it to his friends, Benji took special efforts to ensure Kate was cared for. He got up every day and made her breakfast from what little food they had, helped her comb her hair and walked her to school. If she really pleaded, he’d stay and attend school as he should have. He always walked her home, got her a snack and made sure she was safely entertained in her bedroom, before he’d run out the front door again.&lt;br /&gt;
&lt;br /&gt;
His first two years at Hogwarts centered around Benji really trying to understand his trauma, how it had affected him and the people around him. He fell into the wrong crowd for awhile, trying to find something that felt like the friends he had back home. He struggled hard with his alcohol addiction he'd been nursing since he was nine - a habit he'd picked up from the older boys to stop the pain after his mother's beatings.&lt;br /&gt;
&lt;br /&gt;
During this time, Benji and his sister were both removed from their parents' custody and placed in an orphanage for magical children.&lt;br /&gt;
&lt;br /&gt;
In his third year, Benji got clean, under the help of his future adoptive mother, Julia and reconciled with the original friends he'd made in first year. He spent all of third year getting to know who really was, what he liked, what he didn't and the kind of friend he wanted to be to people. Over the summer, he and his sister were both adopted into the Laurence family - one of the most powerful Pureblood aristocratic families in Britain, and he is the current heir to the estate and ducal seat.&lt;br /&gt;
 &lt;br /&gt;
[[Category:Hufflepuff]]&lt;br /&gt;
[[Category:Class of 1924]]&lt;br /&gt;
{{DEFAULTSORT:Laurence, Benji}}&lt;br /&gt;
[[Category:Hogwarts Students]]&lt;br /&gt;
[[Category:Written by Amber]]&lt;br /&gt;
[[Category:Prefects]]&lt;br /&gt;
&lt;br /&gt;
{{Character Record&lt;br /&gt;
|DisplayName=Benji Laurence&lt;br /&gt;
|Gender=Male&lt;br /&gt;
|BirthDate=1904-04-04&lt;br /&gt;
|Status=Alive&lt;br /&gt;
|BirthFamily=The Cuddrun Family&lt;br /&gt;
|CurrentFamily=The Laurence Family&lt;br /&gt;
|AdoptiveMother=Julia Laurence&lt;br /&gt;
|BloodStatus=Pureblood&lt;br /&gt;
|Heir=Yes&lt;br /&gt;
|Illegitimate=No&lt;br /&gt;
|Adopted=Yes&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
	<entry>
		<id>https://knockturnbound.net/lexicon/index.php?title=Julia_Laurence&amp;diff=1927</id>
		<title>Julia Laurence</title>
		<link rel="alternate" type="text/html" href="https://knockturnbound.net/lexicon/index.php?title=Julia_Laurence&amp;diff=1927"/>
		<updated>2026-04-15T15:36:14Z</updated>

		<summary type="html">&lt;p&gt;Wylder Merrow: &lt;/p&gt;
&lt;hr /&gt;
&lt;div&gt;{{Infobox character&lt;br /&gt;
| name             = Julia Sybil Barlowe&lt;br /&gt;
| image            = Julia Laurence.jpg &lt;br /&gt;
| gender           = Female&lt;br /&gt;
| nicknames        = Jules&lt;br /&gt;
| born             = 12 July 1886&lt;br /&gt;
| died             = &lt;br /&gt;
| family           = [[The Laurence Family]]&lt;br /&gt;
| bloodtype        = Pureblood&lt;br /&gt;
| social_class     = Nobility&lt;br /&gt;
| house            = Ombrelune (Beauxbatons)&lt;br /&gt;
| graduation_year  = 1904&lt;br /&gt;
| occupation       = Ravenclaw HoH, Dark Arts Professor, Librarian&lt;br /&gt;
| residence        = Arundel Castle, West Sussex, England&lt;br /&gt;
| wand             = Ash Wood, White River Monster Spine Core, 12 3/4 Inches, Rigid&lt;br /&gt;
| patronus         = Crow&lt;br /&gt;
| parents          = William Laurence IV and Vera St. Allswell&lt;br /&gt;
| siblings         = Edward Laurence, [[James Laurence]]&lt;br /&gt;
| significant_other= [[Maddox Barlowe]]&lt;br /&gt;
| children         = [[Benji Laurence]], [[Kathryn Barlowe]], Jude Barlowe, [[Morgan Barlowe]], Evander Barlowe&lt;br /&gt;
| status           = Alive&lt;br /&gt;
}}&lt;br /&gt;
&lt;br /&gt;
== Overview ==&lt;br /&gt;
The first thing to understand about Julia, is her innate sense of duty and obligation to the people she’s loyal to. She will consistently put the people she loves ahead of herself, every time, without question. One would think this would make her selfless. She is anything but. In many ways, Julia is incredibly selfish, but this selfishness is always rooted in the betterment of her family, status and power. She is not afraid to take what she believes to be rightfully hers, and anyone who gets in her way will be met with her colder side.&lt;br /&gt;
&lt;br /&gt;
Inwardly, Julia is an elitist, though it’s very rare to see her portray this outside of the confines of her family’s home. She is a pureblooded noblewoman, and she’s very aware of the status that provides her.&lt;br /&gt;
&lt;br /&gt;
Outwardly, to everyone who meets her, Julia is soft-spoken, unassuming, a well-brought up noblewoman with impeccable manners and propriety. But behind closed doors, she is absolutely, undeniably cutthroat when it comes to her or her family’s ambitions. She is the type to smile in someone’s face, soothe them, comfort them, all while lighting the match to their pyre. Her loyalty is hard to obtain and harder to keep, but when the woman loves, she loves with an intensity that is unshakeable. Her love of course, being reserved for her family and a few close friends.&lt;br /&gt;
&lt;br /&gt;
On a smaller scale, Julia can be kind, surprisingly caring and has a soft spot for children.&lt;br /&gt;
&lt;br /&gt;
Julia has a natural charisma and wit about her that draws people in quickly and easily. She has mastered the subtle art of charming would-be allies and enemies alike with soft words and a sweet, playful demeanor. This ability has been an invaluable tool for her father and brothers in their political and social ambitions. She is incredibly intelligent and combined with her charisma, she is often able to stay two steps ahead of her opponents (whether they realize they are opponents or not).&lt;br /&gt;
&lt;br /&gt;
Her unyielding and often blind devotion to her family is a weakness she has not yet been able to overcome. Raised in an incredibly patriarchal family, she has been groomed to understand her place as a woman in comparison to that of her brothers. She allows them and her father to dictate her almost every move, and she is essentially at their beck-and-call, despite the appearance that she is an independent woman with a career. She will do almost anything they ask of her, to her own detriment. She is also known to take unnecessary risks on their behalf, and she will drop everything she is doing if they summon her.&lt;br /&gt;
&lt;br /&gt;
=== Physical Description ===&lt;br /&gt;
Julia is on the shorter side, around 5’4, often placing her below the height of her older students. You’ll often find her in heels or boots to make up for this. She is slim, with a nice figure. Although she has the money to dress in higher-end fashions, she is rather down-to-earth and prefers casual jeans and sweaters or blouses than anything else. Her medium-length brown hair is often worn down, or pinned back out of her face. Julia’s light brown eyes are the highlight of her features and are often the first thing anyone notices about her. She is usually seen wearing a small smile that reaches her eyes, but if it’s a genuinely happy smile, her dimples will reveal themselves.&lt;br /&gt;
&lt;br /&gt;
=== History ===&lt;br /&gt;
Blood is everything. It is the lifeforce within all living, breathing things. Carrying nutrients, platelets and oxygen throughout our bodies, blood is what serves us, feeds us, defines us. It is what separates sentient from non, and what defines worth over none.&lt;br /&gt;
&lt;br /&gt;
Lady Julia Sybil Laurence was born in a bath of the blood that would define her very existence for the rest of her life. As her mother was swept away into Death’s gentle arms, Julia took her first breaths into the world that would become her oyster.&lt;br /&gt;
&lt;br /&gt;
As the third-born child and only daughter of William Laurence, one of the most powerful dukes in England, it could be assumed that Julia’s life would be laid out in front of her from the moment of conception. However, Lord Laurence was not a shallow or short-sighted man. She would not be sent to some aristo finishing school, or married off to another noble family at the first chance. His daughter, his Julia, was no lady-in-waiting. She would be raised the same as her brothers; educated, calculated, ambitious. Another portrait of pride and significance in the history of the British aristocracy.&lt;br /&gt;
&lt;br /&gt;
As one of the few Pureblood noble families in Britain, the Laurences took their lineage and status extremely seriously. Since the time of the Norman invasion in 1066, every chess move, every intrigue, every whisper in every hallway was calculated carefully, methodically, patiently. Aligning themselves with muggle nobles and royalty had often felt beneath them, but instinctively, they knew:&lt;br /&gt;
&lt;br /&gt;
Everyone had something to offer. Who were they to not capitalize upon it?&lt;br /&gt;
&lt;br /&gt;
It hadn’t always worked out of course. Many of their lineage were lost to tyrants, petty civil war, and jealous rivals over the centuries. But the core of them remained, steadfast in their quest for power and domination in the land.&lt;br /&gt;
&lt;br /&gt;
It was this innate sense of duty and ambition that fueled the blood within Julia Laurence from the time she was a girl. The castle where she grew up seemed tiny to her, compared to the enormous world that awaited her outside. Within the hundreds of books her tutors provided her over the years, Julia found her calling; a desperate need to absorb knowledge. Knowledge about different cultures, the origin of magic, different wizarding families throughout the world, magical history, and the dark arts.&lt;br /&gt;
&lt;br /&gt;
As tradition required, following her private tutoring, Julia was sent to Beauxbatons instead of Hogwarts for her magical education. There, she was well-liked but also well-known to have a certain distaste for those whose ambitions, behavior or lineage left much to be desired. A tone that could be perceived as disrespectful or uncouth would be met with a flash of brown eyes and a sharp reminder of who they were talking to. She flourished in school, receiving nearly perfect marks in her subjects and could often be found re-arranging books in the library during her spare time. She made good friends with the elderly librarian there, who taught her how to properly research, cite and dictate her findings. She wrote several editorials for the school newsletter and volunteered on the rare occasion in the hospital wing. She found, of course, that healing just wasn’t her forte.&lt;br /&gt;
&lt;br /&gt;
After completing Beauxbatons, she followed her elder brothers to the University of Edinburgh, studying Magical History and Archaeology in addition to a few extra classes in Botany and Chemistry on the side. Learning enthralled the girl. The more she absorbed, the more powerful she felt, and the more she relished every book she could get her hands on. Her brothers found her rather amusing, as neither of them seemed to have the same vigor for education and focused more on the social aspects of their university lives. Valuable, Julia concurred, but nowhere near as exciting.&lt;br /&gt;
&lt;br /&gt;
Nothing was more exciting to the woman than something that wasn’t expected of her. And at first glance, or second, no one would take her for a lover of the darker arts. While her father, brothers and extended family cared little about the difference between use of light or dark magic (as it was all the same to them), Julia found herself drawn to the abyss, drinking in page after page of spells, incantations, potions and hexes. She had never used them of course, that anyone knew of. But they were nice to have in her pocket.&lt;br /&gt;
&lt;br /&gt;
Marriage and men had never been on her radar, much less a priority. Marriage was for status, cementing alliances and providing heirs. Julia already had the first two, and her brothers would provide the last.&lt;br /&gt;
&lt;br /&gt;
An unmarried, educated, pureblooded aristo woman was quite the prize in noble Britain, but the only game Julia would humor, was where Queen takes King.&lt;br /&gt;
[[Category:Written by Amber]]&lt;br /&gt;
{{DEFAULTSORT:Laurence, Julia}}&lt;br /&gt;
&lt;br /&gt;
{{Character Record&lt;br /&gt;
|DisplayName=Julia Laurence&lt;br /&gt;
|Gender=Female&lt;br /&gt;
|BirthDate=1886-07-12&lt;br /&gt;
|Status=Alive&lt;br /&gt;
|BirthFamily=The Laurence Family&lt;br /&gt;
|CurrentFamily=The Barlowe Family&lt;br /&gt;
|BloodStatus=Pureblood&lt;br /&gt;
|Title=Lady&lt;br /&gt;
|Heir=No&lt;br /&gt;
|Illegitimate=No&lt;br /&gt;
|Adopted=No&lt;br /&gt;
}}&lt;/div&gt;</summary>
		<author><name>Wylder Merrow</name></author>
	</entry>
</feed>