Index: /clusterlane/tags/1.3.2/admin/css/admin.css
===================================================================
--- /clusterlane/tags/1.3.2/admin/css/admin.css	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/css/admin.css	(revision 3751027)
@@ -0,0 +1,5930 @@
+
+/* Content Cluster Intelligence — Admin styles v0.1 */
+
+/* ── Hero action buttons (inside dark hero) ─────────────────── */
+.clusterlane-site-analysis-card {
+    background: #fff;
+    border: 1.5px solid #e2e8f0;
+    border-radius: 14px;
+    margin-bottom: 20px;
+    box-shadow: 0 2px 8px rgba(15,23,42,.06);
+}
+.clusterlane-site-analysis-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 16px;
+    flex-wrap: wrap;
+    padding: 22px 28px;
+    cursor: pointer;
+    list-style: none;
+    border-radius: 14px;
+}
+.clusterlane-site-analysis-header::-webkit-details-marker { display: none; }
+.clusterlane-site-analysis-header::after {
+    content: "▸";
+    font-size: .95rem;
+    color: #94a3b8;
+    transition: transform 120ms;
+    flex-shrink: 0;
+}
+details.clusterlane-site-analysis-card[open] > .clusterlane-site-analysis-header::after {
+    transform: rotate(90deg);
+}
+details.clusterlane-site-analysis-card[open] > .clusterlane-site-analysis-header {
+    border-radius: 14px 14px 0 0;
+    border-bottom: 1px solid #f1f5f9;
+}
+.clusterlane-site-analysis-title {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+}
+.clusterlane-site-analysis-icon {
+    font-size: 1.7rem;
+    line-height: 1;
+    flex-shrink: 0;
+}
+.clusterlane-site-analysis-title strong {
+    display: block;
+    font-size: 16px;
+    font-weight: 700;
+    color: #0f172a;
+    letter-spacing: -.01em;
+}
+.clusterlane-site-analysis-sub {
+    font-size: 12.5px;
+    color: #64748b;
+    margin-top: 2px;
+}
+#clusterlane-site-analysis-body {
+    padding: 4px 28px 28px;
+}
+.clusterlane-site-analysis-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+}
+.clusterlane-site-analysis-meta {
+    font-size: 11px;
+    color: #94a3b8;
+}
+.clusterlane-sa-btn-wrap {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 4px;
+}
+.clusterlane-sa-ai-badge {
+    font-size: 10px;
+    color: #94a3b8;
+    letter-spacing: .02em;
+    white-space: nowrap;
+}
+.clusterlane-ai-vital-inc { font-style: normal; font-size: 11px; color: #94a3b8; margin-left: 2px; }
+.clusterlane-sa-ai-badge--pro { font-size: 12px; color: #6d28d9; font-weight: 600; }
+.clusterlane-site-analysis-body-inner {
+    margin-top: 0;
+    border-top: none;
+    padding-top: 4px;
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 24px;
+}
+@media (max-width: 800px) {
+    .clusterlane-site-analysis-body-inner { grid-template-columns: 1fr; }
+}
+.clusterlane-sa-overview {
+    grid-column: 1 / -1;
+    font-size: 13.5px;
+    line-height: 1.7;
+    color: #1e293b;
+    background: #f8fafc;
+    border-left: 3px solid #2271b1;
+    border-radius: 0 6px 6px 0;
+    padding: 14px 18px;
+    margin-bottom: 4px;
+}
+.clusterlane-sa-overview .clusterlane-sa-section-title {
+    text-transform: none;
+    letter-spacing: 0;
+    font-size: 14px;
+    font-weight: 700;
+    color: #1e3a8a;
+    margin-bottom: 8px;
+}
+.clusterlane-sa-section-title {
+    font-size: 13px;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .07em;
+    color: #334155;
+    margin: 0 0 12px;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+}
+.clusterlane-sa-strengths {
+    grid-column: 1 / -1;
+}
+.clusterlane-sa-strength-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 10px;
+}
+.clusterlane-sa-strength {
+    background: #f0fdf4;
+    border: 1px solid #bbf7d0;
+    border-radius: 8px;
+    padding: 10px 12px;
+}
+.clusterlane-sa-strength strong {
+    display: block;
+    font-size: 12.5px;
+    color: #14532d;
+    margin-bottom: 4px;
+}
+.clusterlane-sa-strength span {
+    font-size: 12px;
+    color: #166534;
+    line-height: 1.45;
+}
+.clusterlane-sa-opportunities {
+    grid-column: 1 / -1;
+}
+.clusterlane-sa-opp-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 10px;
+}
+.clusterlane-sa-opp {
+    background: #fffbeb;
+    border: 1px solid #fde68a;
+    border-radius: 8px;
+    padding: 10px 12px;
+}
+.clusterlane-sa-opp strong {
+    display: block;
+    font-size: 12.5px;
+    color: #92400e;
+    margin-bottom: 4px;
+}
+.clusterlane-sa-opp span {
+    font-size: 12px;
+    color: #78350f;
+    line-height: 1.45;
+}
+.clusterlane-sa-weak { grid-column: 1 / -1; }
+.clusterlane-sa-weak-chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+}
+.clusterlane-sa-chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 4px 10px;
+    border-radius: 999px;
+    font-size: 11.5px;
+    font-weight: 600;
+}
+.clusterlane-sa-chip em {
+    font-style: normal;
+    font-weight: 700;
+    opacity: .75;
+}
+.clusterlane-sa-chip--warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
+.clusterlane-sa-chip--critical { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
+.clusterlane-sa-actions {
+    grid-column: 1 / -1;
+}
+.clusterlane-sa-action-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+.clusterlane-sa-action {
+    display: flex;
+    align-items: flex-start;
+    gap: 10px;
+    background: #eff6ff;
+    border: 1px solid #bfdbfe;
+    border-radius: 8px;
+    padding: 10px 12px;
+}
+.clusterlane-sa-action-num {
+    flex-shrink: 0;
+    width: 22px;
+    height: 22px;
+    background: #2563eb;
+    color: #fff;
+    border-radius: 50%;
+    font-size: 11px;
+    font-weight: 700;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.clusterlane-sa-action-text strong {
+    display: block;
+    font-size: 13px;
+    color: #1e3a8a;
+    margin-bottom: 2px;
+}
+.clusterlane-sa-action-text span {
+    font-size: 12px;
+    color: #1d4ed8;
+    line-height: 1.45;
+}
+
+/* ── Step labels (AI Link Suggestions) ──────────────────────── */
+.clusterlane-wrap { max-width: 1200px; }
+.clusterlane-title {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    font-size: 1.6rem !important;
+    font-weight: 800 !important;
+    letter-spacing: -.01em;
+    color: #16223a;
+    margin-bottom: 24px !important;
+}
+.clusterlane-title .dashicons { font-size: 1.4rem; width: 1.4rem; height: 1.4rem; color: #2271b1; }
+
+/* Summary cards */
+
+/* Actions / scan section */
+.clusterlane-scan-section {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 18px;
+    padding: 22px 24px;
+    margin-top: 24px;
+    box-shadow: 0 1px 3px rgba(15,23,42,.06), 0 4px 12px rgba(15,23,42,.06);
+}
+
+/* "All Clusters" section on the Dashboard had no card styling at all —
+   give it the same rounded/shadowed box as everything else. */
+.clusterlane-scan-section h2 { margin-top: 0; }
+
+/* Bolder, more distinctive section headings ("All Clusters", etc.) */
+.clusterlane-scan-section h2 {
+    font-size: 1.3rem;
+    font-weight: 800;
+    letter-spacing: -.01em;
+    color: #0f172a;
+}
+
+/* ── Action button grid ──────────────────────────────────────── */
+.clusterlane-action-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 7px;
+    /* Equal width: enough for the longest label */
+    min-width: 158px;
+    justify-content: center;
+    padding: 9px 16px;
+    border-radius: 8px;
+    font-size: 13px;
+    font-weight: 600;
+    line-height: 1;
+    cursor: pointer;
+    text-decoration: none;
+    transition: background 120ms, box-shadow 120ms, opacity 120ms;
+    white-space: nowrap;
+    box-sizing: border-box;
+}
+.clusterlane-action-btn:focus-visible {
+    outline: 2px solid #2271b1;
+    outline-offset: 2px;
+}
+
+/* Primary — one colour for the main action everywhere (0.98.186: bright blue,
+   as Apply in the lists; navy is kept for headers and tabs). */
+.clusterlane-action-btn--primary {
+    background: #2563eb;
+    color: #fff;
+    border: 1.5px solid transparent;
+    box-shadow: 0 1px 3px rgba(37,99,235,.25), 0 3px 8px rgba(37,99,235,.15);
+}
+.clusterlane-action-btn--primary:hover {
+    background: #1d4ed8;
+    box-shadow: 0 2px 6px rgba(37,99,235,.30), 0 4px 12px rgba(37,99,235,.20);
+    color: #fff;
+    text-decoration: none;
+}
+/* Disabled reads as disabled: grey, not a paler blue that still looks clickable. */
+.clusterlane-action-btn--primary:disabled, .clusterlane-action-btn--primary[disabled] {
+    background: #e2e8f0;
+    color: #94a3b8;
+    opacity: 1;
+    cursor: not-allowed;
+    box-shadow: none;
+}
+
+/* Secondary — sync / compute */
+.clusterlane-action-btn--secondary {
+    background: #fff;
+    color: #1e3a5f;
+    border: 1.5px solid #c4d4e8;
+    box-shadow: 0 1px 2px rgba(15,23,42,.05);
+}
+.clusterlane-action-btn--secondary:hover {
+    background: #f0f6ff;
+    border-color: #93b8d8;
+    color: #1e3a5f;
+    text-decoration: none;
+    box-shadow: 0 2px 6px rgba(15,23,42,.08);
+}
+.clusterlane-action-btn--secondary:disabled, .clusterlane-action-btn--secondary[disabled] {
+    opacity: .5;
+    cursor: not-allowed;
+    box-shadow: none;
+}
+
+/* Divider between action + nav groups */
+
+/* Tertiary — cluster admin / settings */
+.clusterlane-action-btn--tertiary {
+    background: #f8fafc;
+    color: #475569;
+    border: 1.5px solid #e2e8f0;
+}
+.clusterlane-action-btn--tertiary:hover {
+    background: #f1f5f9;
+    color: #1e293b;
+    border-color: #cbd5e1;
+    text-decoration: none;
+}
+
+/* Danger — stop */
+.clusterlane-action-btn--danger {
+    background: #fff0f0;
+    color: #b32d2e;
+    border: 1.5px solid #f5c0c0;
+}
+.clusterlane-action-btn--danger:hover {
+    background: #ffe4e4;
+    border-color: #f4a0a0;
+    color: #9a1f1f;
+    text-decoration: none;
+}
+.clusterlane-action-btn__icon {
+    display: flex;
+    align-items: center;
+    flex-shrink: 0;
+    opacity: .85;
+}
+.clusterlane-action-btn--primary .clusterlane-action-btn__icon { opacity: .9; }
+@media (max-width: 700px) {
+    .clusterlane-action-btn { min-width: 0; flex: 1 1 calc(50% - 4px); }
+}
+
+/* Progress bar */
+.clusterlane-progress-bar-wrap {
+    background: #f0f0f0;
+    border-radius: 4px;
+    height: 10px;
+    overflow: hidden;
+    margin-bottom: 8px;
+}
+.clusterlane-progress-bar {
+    height: 100%;
+    background: #2271b1;
+    border-radius: 4px;
+    transition: width .3s ease;
+}
+.clusterlane-progress-label { font-size: .82rem; color: #666; }
+
+/* Status messages */
+.clusterlane-scan-status {
+    margin-top: 12px;
+    font-size: .88rem;
+    color: #444;
+    min-height: 20px;
+}
+.clusterlane-scan-status.success { color: #1a7f37; }
+.clusterlane-scan-status.error { color: #b32d2e; }
+.clusterlane-scan-status.warn { color: #b45309; }
+
+/* Cluster table */
+
+/* Archetype pills */
+.clusterlane-archetype-pill {
+    display: inline-block;
+    font-size: .75rem;
+    font-weight: 600;
+    padding: 2px 8px;
+    border-radius: 10px;
+    background: #e8e8e8;
+    color: #444;
+}
+.clusterlane-archetype-balanced-mesh { background: #d4edda; color: #155724; }
+.clusterlane-archetype-developed-cluster { background: #cce5ff; color: #004085; }
+.clusterlane-archetype-authority-pyramid { background: #d1ecf1; color: #0c5460; }
+.clusterlane-archetype-emerging-cluster { background: #e8e8e8; color: #555; }
+.clusterlane-archetype-weak-structure { background: #fff3cd; color: #856404; }
+.clusterlane-archetype-fragmented { background: #f8d7da; color: #721c24; }
+.clusterlane-archetype-over-centralized-hub { background: #fce8d5; color: #7a3b00; }
+
+/* =========================================================
+   Ported from the "nice" design (museumlite-intelligence):
+   panel cards, archetype distribution bars, health gauge,
+   restyled cluster table + filter box.
+========================================================= */
+.clusterlane-panel-card {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 18px;
+    padding: 22px 24px;
+    box-shadow: 0 1px 3px rgba(15,23,42,.06), 0 4px 12px rgba(15,23,42,.06);
+}
+.clusterlane-panel-card h2 {
+    margin: 0 0 18px;
+    font-size: 14px;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .06em;
+    color: #94a3b8;
+}
+
+/* Archetype distribution bars */
+.clusterlane-arch-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+
+/* Health gauge */
+
+/* Filter box above the cluster table */
+.clusterlane-search-wrap { position: relative; width: 100%; max-width: 340px; }
+.clusterlane-search-icon {
+    position: absolute;
+    left: 12px;
+    top: 50%;
+    transform: translateY(-50%);
+    color: #9ca3af;
+    pointer-events: none;
+    transition: color .15s;
+}
+
+/*
+ * Every .clusterlane-search-input rule below is scoped under .clusterlane-search-wrap
+ * (not just .clusterlane-search-input alone) on purpose: this site loads a
+ * global forms.css whose `input[type="text"], input[type="search"], ...`
+ * rule has specificity (0,1,1) — element + attribute selector — which
+ * beats a bare single-class selector at (0,1,0), regardless of load
+ * order. Nesting under the wrapper class raises us to (0,2,0), which
+ * wins cleanly without needing !important.
+ */
+.clusterlane-search-wrap .clusterlane-search-input {
+    width: 100%;
+    height: 34px;
+    padding: 0 12px 0 38px;
+    border: 1.5px solid #cbd5e1;
+    border-radius: 6px;
+    font-size: 13px;
+    font-family: inherit;
+    color: #0f172a;
+    background: #fff;
+    box-shadow: 0 1px 2px rgba(15,23,42,.04);
+    box-sizing: border-box;
+    transition: border-color .15s, box-shadow .15s;
+}
+.clusterlane-search-wrap .clusterlane-search-input::placeholder { color: #94a3b8; }
+.clusterlane-search-wrap .clusterlane-search-input:hover { border-color: #94a3b8; }
+.clusterlane-search-wrap .clusterlane-search-input:focus {
+    outline: none;
+    border-color: #2271b1;
+    box-shadow: 0 0 0 2px rgba(34,113,177,.15);
+}
+.clusterlane-search-wrap:focus-within .clusterlane-search-icon { color: #2271b1; }
+.clusterlane-type-filters { display: flex; gap: 6px; }
+.clusterlane-type-filter {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 999px;
+    padding: 6px 14px;
+    font-size: 12px;
+    font-weight: 600;
+    color: #64748b;
+    cursor: pointer;
+    transition: all .15s;
+}
+.clusterlane-type-filter:hover { border-color: #94a3b8; color: #334155; }
+.clusterlane-type-filter.active { background: #16223a; border-color: #16223a; color: #fff; }
+
+/* Restyled "All Clusters" table (replaces the plain wp-list-table look) */
+
+/* =========================================================
+   HERO — dark gradient banner replacing the old title + flat
+   KPI-card row, with health ring + archetype mix folded in.
+========================================================= */
+.clusterlane-hero {
+    background: linear-gradient(135deg, #16223a 0%, #22314f 100%);
+    border-radius: 18px;
+    padding: 28px 32px 32px;
+    color: #fff;
+    box-shadow: 0 12px 32px rgba(15,23,42,.20);
+    margin-bottom: 28px;
+}
+.clusterlane-hero-top {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-bottom: 24px;
+}
+.clusterlane-hero-title { display: flex; align-items: center; gap: 14px; }
+.clusterlane-hero-title .dashicons { font-size: 32px; width: 32px; height: 32px; color: #60a5fa; }
+.clusterlane-hero-title h1 {
+    margin: 0;
+    font-size: 26px;
+    font-weight: 900;
+    color: #fff;
+    letter-spacing: -.03em;
+    line-height: 1.1;
+    text-shadow: 0 1px 4px rgba(0,0,0,.2);
+}
+.clusterlane-hero-version {
+    display: inline-block;
+    margin-top: 2px;
+    font-size: 11px;
+    font-weight: 600;
+    color: #93c5fd;
+    background: rgba(96,165,250,.15);
+    padding: 1px 8px;
+    border-radius: 999px;
+}
+.clusterlane-hero-lastscan { font-size: 12px; color: #94a3b8; padding-top: 6px; }
+.clusterlane-hero-body {
+    display: grid;
+    grid-template-columns: 1.3fr auto 1.4fr;
+    gap: 28px;
+    align-items: center;
+}
+@media (max-width: 1000px) {
+    .clusterlane-hero-body { grid-template-columns: 1fr; }
+}
+
+/* Compact stat grid (left) */
+.clusterlane-hero-stats {
+    display: grid;
+    grid-template-columns: repeat(3, auto);
+    gap: 18px 28px;
+}
+.clusterlane-hero-stat { display: flex; flex-direction: column; gap: 2px; }
+.clusterlane-hero-stat-value { font-size: 24px; font-weight: 800; color: #fff; line-height: 1; }
+.clusterlane-hero-stat-value--green { color: #34d399; }
+.clusterlane-hero-stat-value--amber { color: #fbbf24; }
+.clusterlane-hero-stat-value--red { color: #f87171; }
+.clusterlane-hero-stat-label {
+    font-size: 10px;
+    text-transform: uppercase;
+    letter-spacing: .06em;
+    color: #94a3b8;
+}
+
+/* Big health ring (middle) */
+.clusterlane-hero-health { display: flex; flex-direction: column; align-items: center; gap: 10px; }
+.clusterlane-hero-ring { position: relative; width: 132px; height: 132px; }
+.clusterlane-hero-ring svg { transform: rotate(-90deg); }
+.clusterlane-hero-ring-bg { fill: none; stroke: rgba(255,255,255,.10); stroke-width: 12; }
+.clusterlane-hero-ring-fill { fill: none; stroke-width: 12; stroke-linecap: round; transition: stroke-dashoffset .6s ease; }
+.clusterlane-hero-ring-label {
+    position: absolute; inset: 0;
+    display: flex; flex-direction: column;
+    align-items: center; justify-content: center;
+    line-height: 1;
+}
+.clusterlane-hero-ring-value { font-size: 28px; font-weight: 800; }
+.clusterlane-hero-ring-sub { font-size: 10px; text-transform: uppercase; color: #94a3b8; letter-spacing: .06em; margin-top: 4px; }
+.clusterlane-hero-health-desc { font-size: 12px; color: #94a3b8; text-align: center; max-width: 160px; }
+
+/* Archetype stacked bar (right) */
+.clusterlane-hero-archetypes { display: flex; flex-direction: column; gap: 10px; }
+.clusterlane-hero-archetypes-label {
+    font-size: 11px;
+    text-transform: uppercase;
+    letter-spacing: .06em;
+    color: #94a3b8;
+    font-weight: 700;
+}
+.clusterlane-arch-stacked-bar {
+    display: flex;
+    width: 100%;
+    height: 10px;
+    border-radius: 999px;
+    overflow: hidden;
+    background: rgba(255,255,255,.08);
+}
+.clusterlane-arch-seg { height: 100%; transition: width .4s ease; }
+.clusterlane-arch-stacked-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; }
+.clusterlane-arch-legend-item {
+    display: inline-flex; align-items: center; gap: 6px;
+    font-size: 12px; color: #cbd5e1;
+}
+.clusterlane-arch-legend-item strong { color: #fff; font-weight: 700; }
+
+/* Row action buttons — small circular icon buttons instead of the
+   default WP button/button-small look. */
+.clusterlane-row-btn {
+    width: 30px; height: 30px;
+    display: inline-flex; align-items: center; justify-content: center;
+    border: 1px solid #e2e8f0;
+    border-radius: 999px;
+    background: #fff;
+    font-size: 13px;
+    line-height: 1;
+    cursor: pointer;
+    transition: transform .12s, box-shadow .12s, background .12s, border-color .12s;
+    padding: 0;
+}
+.clusterlane-row-btn:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 2px 8px rgba(15,23,42,.12);
+}
+.clusterlane-row-btn--delete:hover { background: #fef2f2; border-color: #fca5a5; }
+
+/* Edit-cluster modal header: title left, cluster stats right. */
+.clusterlane-modal-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 16px;
+    padding-right: 28px; /* clears the absolutely-positioned close (×) button */
+}
+.clusterlane-modal-title {
+    margin: 0;
+    font-size: 1.3rem;
+    font-weight: 800;
+    letter-spacing: -.01em;
+    color: #16223a;
+}
+.clusterlane-modal-stats {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+    justify-content: flex-end;
+    padding-top: 5px;
+}
+.clusterlane-modal-stat {
+    font-size: .78rem;
+    color: #64748b;
+    white-space: nowrap;
+}
+.clusterlane-modal-stat strong { color: #0f172a; font-weight: 700; }
+.clusterlane-modal-stat--type {
+    font-weight: 600;
+    color: #475569;
+    background: #f1f5f9;
+    padding: 3px 10px;
+    border-radius: 999px;
+}
+
+/* Member rows in the modal's "Current members" list. */
+.clusterlane-modal-member-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 8px 10px;
+    border-bottom: 1px solid #f5f5f5;
+}
+.clusterlane-modal-member-row:last-child { border-bottom: none; }
+.clusterlane-modal-member-title { font-size: .85rem; color: #1e293b; }
+.clusterlane-modal-member-row .clusterlane-row-btn { width: 26px; height: 26px; font-size: 12px; flex-shrink: 0; }
+
+/* Expand button overlaid on the graph canvas (top-right corner). */
+.clusterlane-graph-expand-btn {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 5;
+    background: #1d2327;
+    color: #fff;
+    border: none;
+    border-radius: 999px;
+    padding: 6px 14px;
+    font-size: .78rem;
+    font-weight: 600;
+    cursor: pointer;
+    box-shadow: 0 2px 8px rgba(15,23,42,.18);
+    transition: background .15s, transform .12s;
+}
+.clusterlane-graph-expand-btn:hover { background: #16223a; transform: translateY(-1px); }
+
+/* Divider marking where the graph-only stats (Entropy/Gini/Density/Links)
+   join the base modal stats (Type/Articles/Health/Archetype) once the
+   Graph tab has loaded. */
+.clusterlane-modal-stat--graph-first { border-left: 1px solid #e2e8f0; padding-left: 10px; }
+
+/* Status text + legend, in a rounded card below the graph canvas. */
+.clusterlane-graph-overlay {
+    margin-top: 10px;
+    background: #fff;
+    border: 1px solid #eef0f3;
+    border-radius: 12px;
+    padding: 8px 12px;
+    box-shadow: 0 4px 14px -6px rgba(15,23,41,.12), 0 1px 4px rgba(15,23,41,.04);
+}
+.clusterlane-graph-status { font-size: .72rem; color: #7c8592; white-space: nowrap; }
+.clusterlane-graph-legend {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-top: 6px;
+    padding-top: 6px;
+    border-top: 1px solid #f1f3f5;
+}
+.clusterlane-graph-legend-item {
+    display: inline-flex;
+    align-items: center;
+    gap: 5px;
+    font-size: 10.5px;
+    color: #475569;
+    background: #f8fafc;
+    border: 1px solid #eef0f3;
+    border-radius: 999px;
+    padding: 3px 9px 3px 7px;
+    white-space: nowrap;
+}
+
+/* ---------------------------------------------------------------------
+   Gap Analysis tab
+------------------------------------------------------------------- */
+.clusterlane-gap-loading {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 24px 8px;
+    color: #64748b;
+    font-size: .88rem;
+}
+.clusterlane-gap-spinner {
+    width: 15px; height: 15px;
+    border: 2px solid #e2e8f0;
+    border-top-color: #16223a;
+    border-radius: 50%;
+    display: inline-block;
+    animation: clusterlane-spin .7s linear infinite;
+}
+@keyframes clusterlane-spin { to { transform: rotate(360deg); } }
+.clusterlane-gap-error {
+    padding: 12px 14px;
+    background: #fef2f2;
+    border: 1px solid #fecaca;
+    border-radius: 10px;
+    color: #991b1b;
+    font-size: .85rem;
+    line-height: 1.6;
+}
+.clusterlane-gap-error a, .clusterlane-gap-retry {
+    color: #b91c1c;
+    font-weight: 600;
+    text-decoration: underline;
+    background: none;
+    border: none;
+    padding: 0;
+    cursor: pointer;
+    font-size: .85rem;
+}
+.clusterlane-gap-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 10px;
+}
+.clusterlane-gap-badge {
+    display: inline-block;
+    font-size: .72rem;
+    font-weight: 600;
+    padding: 3px 10px;
+    border-radius: 999px;
+}
+.clusterlane-gap-badge--cached { background: #f1f5f9; color: #64748b; }
+.clusterlane-gap-badge--fresh { background: #dcfce7; color: #15803d; }
+.clusterlane-gap-regenerate {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 999px;
+    padding: 5px 12px;
+    font-size: .78rem;
+    font-weight: 600;
+    color: #475569;
+    cursor: pointer;
+    transition: border-color .15s, color .15s;
+}
+.clusterlane-gap-regenerate:hover { border-color: #94a3b8; color: #16223a; }
+.clusterlane-gap-analysis {
+    padding: 12px 14px;
+    background: #f0f6fc;
+    border-left: 3px solid #2271b1;
+    border-radius: 0 10px 10px 0;
+    font-size: .87rem;
+    color: #1d2327;
+    line-height: 1.6;
+    margin-bottom: 16px;
+}
+.clusterlane-gap-suggestions {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+    gap: 12px;
+}
+.clusterlane-gap-card {
+    border: 1px solid #eef0f3;
+    border-radius: 12px;
+    padding: 14px;
+    background: #fff;
+    box-shadow: 0 1px 3px rgba(15,23,41,.04);
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+.clusterlane-gap-card-title {
+    font-weight: 700;
+    font-size: .92rem;
+    color: #16223a;
+    line-height: 1.35;
+}
+.clusterlane-gap-card-rationale {
+    font-size: .8rem;
+    color: #64748b;
+    line-height: 1.5;
+    flex: 1;
+}
+.clusterlane-gap-draft-btn {
+    align-self: flex-start;
+    background: #16223a;
+    color: #fff;
+    border: none;
+    border-radius: 999px;
+    padding: 7px 14px;
+    font-size: .78rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: background .15s, transform .12s;
+}
+.clusterlane-gap-draft-btn:hover { background: #1d2b45; transform: translateY(-1px); }
+.clusterlane-gap-draft-btn:disabled { opacity: .6; cursor: default; transform: none; }
+.clusterlane-gap-draft-done {
+    align-self: flex-start;
+    font-size: .78rem;
+    font-weight: 600;
+    color: #15803d;
+    text-decoration: none;
+    background: #dcfce7;
+    border-radius: 999px;
+    padding: 7px 14px;
+}
+.clusterlane-gap-draft-done:hover { text-decoration: underline; }
+
+/* ---------------------------------------------------------------------
+   AI Link Suggestions page
+------------------------------------------------------------------- */
+.clusterlane-ls-search-results {
+    position: relative;
+    max-width: 420px;
+    margin-top: 6px;
+    border: 1px solid #eef0f3;
+    border-radius: 10px;
+    background: #fff;
+    box-shadow: 0 8px 20px -6px rgba(15,23,41,.14);
+    max-height: 240px;
+    overflow-y: auto;
+    z-index: 5;
+}
+
+/* ---------------------------------------------------------------------
+   AI Link Suggestion — Apply modal
+------------------------------------------------------------------- */
+.clusterlane-ls-modal-overlay {
+    position: fixed;
+    inset: 0;
+    background: rgba(15,23,41,.55);
+    z-index: 100000;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 24px;
+}
+.clusterlane-ls-modal {
+    background: #fff;
+    border-radius: 12px;
+    width: 100%;
+    max-width: 1320px;
+    max-height: 92vh;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    box-shadow: 0 20px 60px -10px rgba(15,23,41,.35);
+}
+.clusterlane-ls-modal-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    padding: 20px 24px;
+    border-bottom: 1px solid #eef0f3;
+    flex-shrink: 0;
+}
+.clusterlane-ls-modal-header h2 { margin: 0 0 2px; font-size: 1.15rem; font-weight: 800; color: #16223a; }
+.clusterlane-ls-modal-close {
+    background: none;
+    border: none;
+    font-size: 1.5rem;
+    line-height: 1;
+    color: #94a3b8;
+    cursor: pointer;
+    padding: 2px 6px;
+}
+.clusterlane-ls-modal-close:hover { color: #16223a; }
+.clusterlane-ls-modal-body {
+    display: flex;
+    flex: 1;
+    overflow: hidden;
+}
+.clusterlane-ls-modal-preview-col {
+    flex: 1.6;
+    min-width: 0;
+    padding: 20px 24px;
+    overflow-y: auto;
+    border-right: 1px solid #eef0f3;
+}
+.clusterlane-ls-modal-controls-col {
+    flex: 1;
+    min-width: 300px;
+    max-width: 380px;
+    padding: 20px 24px;
+    overflow-x: hidden;
+    overflow-y: auto;
+    background: #fafbfc;
+    display: flex;
+    flex-direction: column;
+    box-sizing: border-box;
+}
+.clusterlane-ls-modal-section-label {
+    font-size: .72rem;
+    text-transform: uppercase;
+    letter-spacing: .04em;
+    font-weight: 700;
+    color: #94a3b8;
+    margin-bottom: 4px;
+}
+.clusterlane-ls-preview-frame {
+    margin-top: 10px;
+    border: 1px solid #eef0f3;
+    border-radius: 10px;
+    overflow: hidden;
+    min-height: 500px;
+}
+.clusterlane-ls-preview-frame .clusterlane-gap-loading, .clusterlane-ls-preview-frame .clusterlane-gap-error {
+    margin: 20px 24px;
+}
+.clusterlane-ls-preview-iframe {
+    display: block;
+    width: 100%;
+    height: 560px;
+    border: none;
+}
+.clusterlane-ls-field-label {
+    display: block;
+    font-size: .72rem;
+    text-transform: uppercase;
+    letter-spacing: .04em;
+    font-weight: 700;
+    color: #64748b;
+    margin: 14px 0 4px;
+}
+.clusterlane-ls-field-input, .clusterlane-ls-field-textarea {
+    width: 100%;
+    padding: 7px 11px;
+    border: 1.5px solid #cbd5e1;
+    border-radius: 6px;
+    font-size: 13px;
+    font-family: inherit;
+    background: #fff;
+    box-sizing: border-box;
+}
+.clusterlane-ls-field-textarea { resize: vertical; }
+.clusterlane-ls-field-input:focus, .clusterlane-ls-field-textarea:focus {
+    outline: none;
+    border-color: #2271b1;
+    box-shadow: 0 0 0 2px rgba(34,113,177,.15);
+}
+.clusterlane-ls-apply-final-btn {
+    width: 100%;
+    margin-top: auto;
+    background: #16223a;
+    color: #fff;
+    border: none;
+    border-radius: 8px;
+    padding: 11px 16px;
+    font-size: .88rem;
+    font-weight: 700;
+    cursor: pointer;
+    transition: background .15s;
+}
+.clusterlane-ls-apply-final-btn:hover { background: #1d2b45; }
+.clusterlane-ls-apply-final-btn:disabled { opacity: .65; cursor: default; }
+.clusterlane-ls-apply-status { margin-top: 10px; font-size: .82rem; }
+.clusterlane-ls-apply-success { color: #15803d; font-weight: 600; }
+.clusterlane-ls-apply-success a { color: #15803d; text-decoration: underline; }
+
+/* ── TMS Category Filters ─────────────────────────────────────── */
+.clusterlane-wrap select.clusterlane-tms-select {
+    font-size: 12.5px !important;
+    font-weight: 500 !important;
+    color: #0f172a !important;
+    background-color: #fff !important;
+    border: 1.5px solid #cbd5e1 !important;
+    border-radius: 6px !important;
+    padding: 6px 28px 6px 10px !important;
+    height: auto !important;
+    line-height: 1.4 !important;
+    appearance: none !important;
+    -webkit-appearance: none !important;
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 16 16'%3E%3Cpath fill='%2364748b' d='M4 6l4 4 4-4'/%3E%3C/svg%3E") !important;
+    background-repeat: no-repeat !important;
+    background-position: right 9px center !important;
+    cursor: pointer !important;
+    min-width: 155px !important;
+    box-shadow: 0 1px 2px rgba(15,23,42,.05) !important;
+    transition: border-color .15s, box-shadow .15s;
+}
+.clusterlane-wrap select.clusterlane-tms-select:hover {
+    border-color: #94a3b8 !important;
+}
+.clusterlane-wrap select.clusterlane-tms-select:focus {
+    outline: none !important;
+    border-color: #16223a !important;
+    box-shadow: 0 0 0 3px rgba(15,25,41,.08) !important;
+}
+.clusterlane-wrap select.clusterlane-tms-select:disabled {
+    opacity: .45 !important;
+    cursor: default !important;
+}
+
+/* ── Settings page form fields ───────────────────────────────── */
+.clusterlane-settings-section .form-table { border-collapse: collapse; width: 100%; }
+.clusterlane-settings-section .form-table th {
+    width: 200px;
+    padding: 12px 16px 12px 0;
+    font-size: 13px;
+    font-weight: 600;
+    color: #374151;
+    vertical-align: top;
+    text-align: left;
+}
+.clusterlane-settings-section .form-table td {
+    padding: 10px 0 14px;
+    vertical-align: top;
+}
+.clusterlane-settings-section .form-table tr + tr th, .clusterlane-settings-section .form-table tr + tr td {
+    border-top: 1px solid #f1f5f9;
+}
+
+/* Inputs */
+.clusterlane-wrap .clusterlane-settings-section input[type="text"], .clusterlane-wrap .clusterlane-settings-section input[type="number"], .clusterlane-wrap .clusterlane-settings-section input[type="password"], .clusterlane-wrap .clusterlane-settings-section select, .clusterlane-wrap .clusterlane-settings-section textarea {
+    font-size: 13px !important;
+    font-family: inherit !important;
+    color: #0f172a !important;
+    background: #fff !important;
+    border: 1.5px solid #cbd5e1 !important;
+    border-radius: 6px !important;
+    padding: 7px 11px !important;
+    height: 36px !important;
+    line-height: 20px !important;
+    box-shadow: 0 1px 2px rgba(15,23,42,.04) !important;
+    transition: border-color .15s, box-shadow .15s !important;
+    box-sizing: border-box !important;
+}
+.clusterlane-wrap .clusterlane-settings-section input[type="text"]:focus, .clusterlane-wrap .clusterlane-settings-section input[type="number"]:focus, .clusterlane-wrap .clusterlane-settings-section input[type="password"]:focus, .clusterlane-wrap .clusterlane-settings-section select:focus, .clusterlane-wrap .clusterlane-settings-section textarea:focus {
+    outline: none !important;
+    border-color: #2271b1 !important;
+    box-shadow: 0 0 0 2px rgba(34,113,177,.15) !important;
+}
+.clusterlane-wrap .clusterlane-settings-section select {
+    appearance: none !important;
+    -webkit-appearance: none !important;
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 16 16'%3E%3Cpath fill='%2364748b' d='M4 6l4 4 4-4'/%3E%3C/svg%3E") !important;
+    background-repeat: no-repeat !important;
+    background-position: right 10px center !important;
+    padding-right: 28px !important;
+}
+.clusterlane-wrap .clusterlane-settings-section textarea {
+    resize: vertical !important;
+    height: auto !important;
+    min-height: 80px !important;
+}
+
+/* Description text */
+.clusterlane-settings-section .description, .clusterlane-settings-section p.description {
+    font-size: 12px !important;
+    color: #64748b !important;
+    margin-top: 5px !important;
+    line-height: 1.5 !important;
+}
+
+/* Checkbox labels */
+.clusterlane-settings-section input[type="checkbox"] {
+    margin-right: 5px !important;
+    accent-color: #16223a !important;
+}
+
+/* Range sliders */
+.clusterlane-settings-section input[type="range"] {
+    accent-color: #16223a !important;
+    height: 4px !important;
+    border: none !important;
+    box-shadow: none !important;
+    padding: 0 !important;
+    background: transparent !important;
+}
+.clusterlane-settings-number { width: 80px !important; }
+.clusterlane-settings-number--sm { width: 65px !important; }
+
+/* ── Settings page sections ──────────────────────────────────── */
+.clusterlane-settings-section {
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 18px;
+    padding: 0;
+    margin-bottom: 16px;
+    box-shadow: 0 1px 3px rgba(15,23,42,.06), 0 4px 12px rgba(15,23,42,.06);
+}
+.clusterlane-settings-summary {
+    list-style: none;
+    display: flex;
+    align-items: center;
+    padding: 16px 22px;
+    cursor: pointer;
+    border-radius: 18px;
+    user-select: none;
+}
+.clusterlane-settings-summary::-webkit-details-marker { display: none; }
+.clusterlane-settings-summary::after {
+    content: "▸";
+    margin-left: auto;
+    font-size: .95rem;
+    color: #94a3b8;
+    transition: transform 120ms;
+}
+details[open] > .clusterlane-settings-summary::after { transform: rotate(90deg); }
+details[open] > .clusterlane-settings-summary {
+    border-radius: 18px 18px 0 0;
+    border-bottom: 1px solid #f1f5f9;
+}
+.clusterlane-settings-section h2 {
+    margin: 0;
+    font-size: 1rem;
+    font-weight: 700;
+    letter-spacing: -.01em;
+    color: #0f172a;
+}
+.clusterlane-section-body { padding: 14px 22px 18px; }
+
+/* ── Link Card Templates ─────────────────────────────────────── */
+.clusterlane-lt-row {
+    border: 1px solid #e2e8f0;
+    border-radius: 10px;
+    padding: 14px 16px;
+    margin-bottom: 12px;
+    background: #fff;
+}
+.clusterlane-lt-card-header {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 10px;
+}
+.clusterlane-lt-name-input {
+    flex: 1;
+    font-size: 13px;
+    font-weight: 600;
+}
+.clusterlane-lt-preview-area {
+    margin: 8px 0 10px;
+    font-size: 13px;
+    min-height: 20px;
+}
+.clusterlane-lt-preview-area blockquote {
+    margin: 0 !important;
+}
+.clusterlane-lt-edit-details {
+    border-top: 1px solid #f1f5f9;
+    padding-top: 10px;
+    margin-top: 4px;
+}
+.clusterlane-lt-edit-details > summary.clusterlane-lt-edit-summary {
+    font-size: 12px;
+    font-weight: 600;
+    color: #64748b;
+    cursor: pointer;
+    list-style: none;
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    user-select: none;
+}
+.clusterlane-lt-edit-details > summary.clusterlane-lt-edit-summary::-webkit-details-marker { display: none; }
+.clusterlane-lt-edit-details > summary.clusterlane-lt-edit-summary::before {
+    content: "▸";
+    font-size: 10px;
+    transition: transform 120ms;
+}
+.clusterlane-lt-edit-details[open] > summary.clusterlane-lt-edit-summary::before {
+    transform: rotate(90deg);
+}
+.clusterlane-lt-controls {
+    margin-top: 12px;
+}
+.clusterlane-lt-control-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 12px;
+    margin-bottom: 10px;
+    align-items: flex-end;
+}
+.clusterlane-lt-control-label {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    font-size: 11px;
+    font-weight: 600;
+    color: #64748b;
+    text-transform: uppercase;
+    letter-spacing: .04em;
+}
+.clusterlane-lt-control-label input[type="text"] {
+    font-size: 12.5px !important;
+    padding: 5px 8px !important;
+    width: 140px;
+}
+.clusterlane-lt-control-label input[type="color"] {
+    width: 80px;
+    height: 30px;
+    padding: 2px !important;
+    cursor: pointer;
+    border-radius: 5px !important;
+}
+.clusterlane-lt-control-label input[type="number"] {
+    font-size: 12.5px !important;
+    padding: 5px 8px !important;
+}
+.clusterlane-lt-checkbox-label {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12.5px;
+    color: #374151;
+    margin-bottom: 10px;
+    cursor: pointer;
+}
+.clusterlane-lt-raw-details {
+    margin-top: 10px;
+    border-top: 1px solid #f1f5f9;
+    padding-top: 8px;
+}
+.clusterlane-lt-raw-summary {
+    font-size: 11px;
+    font-weight: 600;
+    color: #94a3b8;
+    cursor: pointer;
+    list-style: none;
+}
+.clusterlane-lt-raw-summary::-webkit-details-marker { display: none; }
+.clusterlane-lt-placeholder-hint {
+    font-size: 11px;
+    color: #94a3b8;
+    margin: 6px 0;
+    line-height: 1.6;
+}
+.clusterlane-lt-raw-html {
+    width: 100%;
+    font-family: monospace;
+    font-size: 12px;
+    margin-top: 6px;
+    resize: vertical;
+}
+.clusterlane-lt-intro {
+    font-size: 13px;
+    color: #475569;
+    margin-bottom: 14px;
+    padding: 10px 14px;
+    background: #f8fafc;
+    border-radius: 7px;
+    border-left: 3px solid #cbd5e1;
+}
+
+/* ==========================================================================
+   Article Inspector — dropdown result items
+   ========================================================================== */
+.clusterlane-ls-result-item {
+    padding: 10px 14px;
+    font-size: .855rem;
+    color: #1e293b;
+    cursor: pointer;
+    border-bottom: 1px solid #f5f5f5;
+    line-height: 1.4;
+}
+.clusterlane-ls-result-item:last-child { border-bottom: none; }
+.clusterlane-ls-result-item:hover, .clusterlane-ls-result-item:focus { background: #f0f7ff; outline: none; }
+.clusterlane-ls-result-title { display: block; }
+
+/* ==========================================================================
+   Article Inspector
+   ========================================================================== */
+
+/* Two-column grid */
+
+/* Post header */
+.clusterlane-ai-post-meta {
+    margin-top: 10px;
+}
+.clusterlane-ai-tax-pill {
+    display: inline-block;
+    padding: 2px 9px;
+    border-radius: 12px;
+    font-size: 11px;
+    font-weight: 600;
+    margin-right: 4px;
+    margin-top: 4px;
+}
+.clusterlane-ai-tax-pill--cat {
+    background: #e0f2fe;
+    color: #0369a1;
+}
+.clusterlane-ai-tax-pill--tag {
+    background: #f0fdf4;
+    color: #166534;
+}
+
+/* Section headings */
+.clusterlane-ai-section-title {
+    font-size: 13px;
+    font-weight: 700;
+    color: #1e293b;
+    margin: 0 0 14px;
+    padding-bottom: 8px;
+    border-bottom: 1px solid #f1f5f9;
+}
+
+/* Metrics grid */
+
+/* No data placeholder */
+.clusterlane-ai-no-data {
+    color: #94a3b8;
+    font-size: 13px;
+    margin: 0;
+    font-style: italic;
+}
+
+/* Links summary KPIs */
+
+/* Expandable link lists */
+.clusterlane-ai-links-details {
+    margin-top: 8px;
+    border: 1px solid #f1f5f9;
+    border-radius: 6px;
+    overflow: hidden;
+}
+.clusterlane-ai-links-details + .clusterlane-ai-links-details {
+    margin-top: 6px;
+}
+.clusterlane-ai-links-details > summary {
+    cursor: pointer;
+    padding: 8px 12px;
+    font-size: 12.5px;
+    font-weight: 600;
+    color: #334155;
+    background: #f8fafc;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    list-style: none;
+    user-select: none;
+}
+.clusterlane-ai-links-details > summary::-webkit-details-marker { display: none; }
+.clusterlane-ai-links-details > summary::before {
+    content: "▶";
+    font-size: 9px;
+    color: #94a3b8;
+    transition: transform .15s;
+}
+.clusterlane-ai-links-details[open] > summary::before {
+    transform: rotate( 90deg );
+}
+.clusterlane-ai-link-list {
+    margin: 0;
+    padding: 8px 12px;
+    list-style: none;
+    max-height: 200px;
+    overflow-y: auto;
+}
+.clusterlane-ai-link-list li + li { border-top: 1px solid #f8fafc; }
+.clusterlane-ai-link-list li {
+    padding: 4px 0;
+    font-size: 12.5px;
+}
+.clusterlane-ai-link-list a { color: #2563eb; text-decoration: none; }
+.clusterlane-ai-link-list a:hover { text-decoration: underline; }
+
+/* Count badge */
+.clusterlane-ai-count-badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    background: #e2e8f0;
+    color: #475569;
+    border-radius: 10px;
+    font-size: 11px;
+    font-weight: 700;
+    padding: 1px 6px;
+    margin-left: 4px;
+}
+
+/* Cluster rows */
+.clusterlane-ai-cluster-row {
+    display: flex;
+    align-items: center;
+    gap: 7px;
+    padding: 8px 10px;
+    border-bottom: 1px solid #f1f5f9;
+    border-radius: 6px;
+    text-decoration: none;
+    color: inherit;
+    transition: background .1s;
+    flex-wrap: nowrap;
+    overflow: hidden;
+}
+.clusterlane-ai-cluster-row:last-child { border-bottom: none; }
+.clusterlane-ai-cluster-row:hover { background: #f8fafc; }
+.clusterlane-ai-cluster-type { font-size: 13px; flex-shrink: 0; }
+.clusterlane-ai-cluster-name {
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    flex: 1;
+    min-width: 0;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.clusterlane-ai-cluster-health {
+    font-size: 12px;
+    font-weight: 700;
+    flex-shrink: 0;
+}
+.clusterlane-ai-cluster-pct {
+    font-size: 11px;
+    color: #94a3b8;
+    flex-shrink: 0;
+}
+.clusterlane-ai-cluster-arrow {
+    font-size: 12px;
+    color: #cbd5e1;
+    flex-shrink: 0;
+    margin-left: 2px;
+}
+
+/* Role pills */
+.clusterlane-ai-role-pill {
+    display: inline-block;
+    padding: 1px 7px;
+    border-radius: 10px;
+    font-size: 10px;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .04em;
+}
+.clusterlane-ai-role-pill--pillar { background: #fdeadf; color: #b4491a; }
+.clusterlane-ai-role-pill--support { background: #fdf4d4; color: #86600a; }
+.clusterlane-ai-role-pill--light { background: #edf4e4; color: #4a6b2a; }
+.clusterlane-ai-role-pill--orphan { background: #e4f0f8; color: #245f84; }
+
+/* TMS section */
+
+/* Article Inspector — ego graph */
+.clusterlane-ai-graph-card {
+    margin-top: 16px;
+}
+.clusterlane-ai-graph-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    flex-wrap: wrap;
+    margin-bottom: 12px;
+}
+.clusterlane-ai-graph-wrap {
+    position: relative;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    overflow: hidden;
+    background: #fafbfc;
+}
+#clusterlane-ai-ego-graph {
+    width: 100%;
+    height: 420px;
+}
+.clusterlane-ai-graph-legend {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+    margin-top: 10px;
+    font-size: 11px;
+    color: #64748b;
+}
+.clusterlane-ai-graph-overlay {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: rgba(250,251,252,.8);
+    font-size: 13px;
+    color: #64748b;
+    pointer-events: none;
+}
+.clusterlane-ai-graph-overlay.hidden { display: none; }
+
+/* === AI ANALYSIS CARD === */
+.clusterlane-ai-analysis-header {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 12px;
+}
+.clusterlane-ai-analysis-title-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+.clusterlane-ai-analysis-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+.clusterlane-ai-cache-note {
+    font-size: 11px;
+    color: #94a3b8;
+    font-weight: 400;
+    margin-left: 4px;
+}
+.clusterlane-ai-analysis-section {
+    margin-top: 20px;
+    padding-top: 18px;
+    border-top: 1px solid #e2e8f0;
+}
+.clusterlane-ai-analysis-section:first-child {
+    margin-top: 0;
+    padding-top: 0;
+    border-top: none;
+}
+.clusterlane-ai-analysis-section-title {
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    margin: 0 0 10px;
+}
+.clusterlane-ai-analysis-text {
+    margin: 0;
+    font-size: 13px;
+    line-height: 1.65;
+    color: #334155;
+}
+.clusterlane-ai-analysis-list {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+.clusterlane-ai-analysis-item {
+    display: grid;
+    grid-template-columns: 160px 1fr;
+    gap: 10px;
+    align-items: baseline;
+    background: #f8fafc;
+    border-radius: 6px;
+    padding: 10px 14px;
+    border-left: 3px solid #3b82f6;
+}
+.clusterlane-ai-analysis-item--sister {
+    border-left-color: #10b981;
+    grid-template-columns: 1fr;
+}
+.clusterlane-ai-analysis-item--sister .clusterlane-ai-analysis-item-label {
+    font-style: italic;
+    color: #0f766e;
+}
+.clusterlane-ai-analysis-item-label {
+    font-size: 12px;
+    font-weight: 600;
+    color: #1e293b;
+    line-height: 1.4;
+}
+.clusterlane-ai-analysis-item-text {
+    font-size: 12px;
+    color: #475569;
+    line-height: 1.55;
+}
+@media (max-width: 640px) {
+    .clusterlane-ai-analysis-item {
+        grid-template-columns: 1fr;
+    }
+}
+
+/* === RECENT ARTICLES PANEL === */
+.clusterlane-ai-recent-list {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+.clusterlane-ai-recent-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    background: #f8fafc;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    padding: 8px 12px;
+    cursor: pointer;
+    text-align: left;
+    transition: background .12s, border-color .12s;
+    width: 100%;
+    max-width: 480px;
+}
+.clusterlane-ai-recent-item:hover {
+    background: #eff6ff;
+    border-color: #bfdbfe;
+}
+.clusterlane-ai-recent-icon {
+    font-size: 13px;
+    flex-shrink: 0;
+    opacity: .6;
+}
+.clusterlane-ai-recent-title {
+    font-size: 13px;
+    color: #1e40af;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* === POST HEADER FEATURED IMAGE BANNER === */
+
+/* Title row overlaid on image */
+
+/* Meta row below image */
+
+/* Pill colours adapt on dark banner */
+
+/* === POST HEADER BANNER — FIXES === */
+
+/* 1. Knappar inne i bilden — uppe till höger, glassmorphism */
+
+/* 2. Pills utanför bilden — återställ till normal färg */
+
+/* 3. Meta-text (datum) normal färg utanför bilden */
+
+/* ===================================================================
+   CLUSTER INSPECTOR
+   =================================================================== */
+
+/* Hero */
+
+/* Shared page hero (slim — no stats) */
+.clusterlane-page-hero {
+    background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%);
+    border-radius: 12px;
+    padding: 20px 28px;
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    margin-top: 16px;
+}
+.clusterlane-page-hero h1, .clusterlane-ci-hero-title h1 {
+    margin: 0 !important;
+    color: #fff !important;
+    font-size: 1.6rem !important;
+    font-weight: 800 !important;
+    letter-spacing: -.01em !important;
+    line-height: 1.15 !important;
+}
+.clusterlane-page-hero-sub {
+    color: rgba(255,255,255,.6);
+    font-size: .8rem;
+    margin-top: 3px;
+    display: block;
+}
+.clusterlane-page-hero + * {
+    margin-top: 16px !important;
+}
+.clusterlane-ci-hero {
+    background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%);
+    border-radius: 12px;
+    padding: 24px 28px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 20px;
+    margin-top: 16px;
+}
+.clusterlane-ci-hero-title {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+.clusterlane-ci-hero-stats {
+    display: flex;
+    gap: 28px;
+    flex-wrap: wrap;
+}
+.clusterlane-ci-stat {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 2px;
+}
+.clusterlane-ci-stat-value {
+    font-size: 1.5rem;
+    font-weight: 700;
+    color: #fff;
+    line-height: 1;
+}
+.clusterlane-ci-stat-label {
+    font-size: 10px;
+    color: rgba(255,255,255,.55);
+    text-transform: uppercase;
+    letter-spacing: .05em;
+    white-space: nowrap;
+}
+
+/* Cluster list rows */
+.clusterlane-ci-cluster-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 11px 14px;
+    border-radius: 8px;
+    cursor: pointer;
+    border: 1px solid transparent;
+    transition: background .1s, border-color .1s;
+    gap: 12px;
+}
+.clusterlane-ci-cluster-row:hover {
+    background: #f8fafc;
+    border-color: #e2e8f0;
+}
+.clusterlane-ci-cluster-row--active {
+    background: #eff6ff;
+    border-color: #bfdbfe;
+}
+.clusterlane-ci-cluster-type {
+    font-size: 14px;
+    flex-shrink: 0;
+}
+
+/* Detail panel header */
+.clusterlane-ci-detail-header {
+    background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
+    padding: 20px 24px 16px;
+}
+.clusterlane-ci-detail-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 10px;
+}
+.clusterlane-ci-detail-label {
+    margin: 0;
+    font-size: 1.1rem;
+    color: #fff;
+    font-weight: 700;
+}
+.clusterlane-ci-detail-close {
+    background: rgba(255,255,255,.12);
+    border: none;
+    color: #fff;
+    font-size: 1.2rem;
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    cursor: pointer;
+    line-height: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: background .12s;
+}
+.clusterlane-ci-detail-close:hover {
+    background: rgba(255,255,255,.25);
+}
+.clusterlane-ci-detail-stats {
+    display: flex;
+    gap: 16px;
+    flex-wrap: wrap;
+}
+.clusterlane-ci-detail-stat {
+    font-size: 12px;
+    color: rgba(255,255,255,.7);
+}
+.clusterlane-ci-detail-stat strong {
+    color: #fff;
+}
+.clusterlane-cluster-ai-stale-banner {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+    background: #fefce8;
+    border: 1px solid #fde047;
+    border-radius: 6px;
+    padding: 8px 12px;
+    margin-bottom: 14px;
+    font-size: 12px;
+    color: #854d0e;
+    line-height: 1.4;
+}
+.clusterlane-cluster-ai-stale-regen {
+    margin-left: auto;
+    background: #fff;
+    border: 1px solid #fbbf24;
+    border-radius: 5px;
+    color: #92400e;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 3px 10px;
+    cursor: pointer;
+    white-space: nowrap;
+    flex-shrink: 0;
+}
+.clusterlane-cluster-ai-stale-regen:hover {
+    background: #fef9c3;
+}
+
+/* ── First-run onboarding modal ─────────────────────────────── */
+.clusterlane-onboarding-overlay {
+    position: fixed;
+    inset: 0;
+    z-index: 999999;
+    background: rgba(15,23,42,.75);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 24px;
+    box-sizing: border-box;
+}
+.clusterlane-onboarding-modal {
+    background: #fff;
+    border-radius: 16px;
+    width: 100%;
+    max-width: 560px;
+    max-height: 90vh;
+    overflow-y: auto;
+    padding: 40px 40px 28px;
+    box-shadow: 0 24px 80px rgba(0,0,0,.35);
+    position: relative;
+    display: flex;
+    flex-direction: column;
+}
+.clusterlane-ob-icon {
+    font-size: 3rem;
+    text-align: center;
+    margin-bottom: 16px;
+    line-height: 1;
+}
+.clusterlane-ob-title {
+    font-size: 1.25rem;
+    font-weight: 800;
+    color: #0f172a;
+    margin: 0 0 12px;
+    text-align: center;
+    letter-spacing: -.01em;
+}
+.clusterlane-ob-desc {
+    font-size: 14px;
+    color: #475569;
+    line-height: 1.65;
+    margin: 0 0 10px;
+    text-align: center;
+}
+.clusterlane-ob-actions {
+    display: flex;
+    gap: 10px;
+    justify-content: center;
+    margin-top: 24px;
+    flex-wrap: wrap;
+}
+.clusterlane-ob-status {
+    font-size: 12px;
+    color: #ef4444;
+    text-align: center;
+    min-height: 18px;
+    margin-top: 8px;
+}
+
+/* Category list */
+.clusterlane-ob-category-list {
+    max-height: 280px;
+    overflow-y: auto;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    margin-top: 12px;
+}
+.clusterlane-ob-category-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 10px 14px;
+    border-bottom: 1px solid #f1f5f9;
+    cursor: pointer;
+    transition: background .1s;
+}
+.clusterlane-ob-category-item:last-child { border-bottom: none; }
+.clusterlane-ob-category-item:hover { background: #f8fafc; }
+.clusterlane-ob-category-item input[type="checkbox"] {
+    width: 16px; height: 16px; flex-shrink: 0; cursor: pointer;
+    accent-color: #2271b1;
+}
+.clusterlane-ob-cat-name {
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+    flex: 1;
+}
+.clusterlane-ob-cat-count {
+    font-size: 11px;
+    color: #94a3b8;
+    flex-shrink: 0;
+}
+
+/* Step 2 (0.98.177): content types, published pages, cluster groups */
+.clusterlane-ob-scroll { max-height: 56vh; overflow-y: auto; margin: 4px -6px 0; padding: 0 6px; }
+.clusterlane-ob-scroll .clusterlane-ob-category-list { max-height: 220px; margin-top: 8px; }
+.clusterlane-ob-sub { font-size: 13px; font-weight: 700; color: #0f172a; margin: 16px 0 2px; text-transform: none; }
+.clusterlane-ob-sub:first-child { margin-top: 4px; }
+.clusterlane-ob-hint { font-size: 12px; color: #64748b; line-height: 1.5; margin: 0 0 4px; }
+.clusterlane-ob-rendered { margin-top: 12px; padding: 10px 12px; border: 1px solid #bfdbfe; background: #eff6ff; border-radius: 8px; }
+.clusterlane-ob-rendered label { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: #1e293b; cursor: pointer; margin-bottom: 4px; }
+.clusterlane-ob-rendered input[type="checkbox"] { margin-top: 2px; accent-color: #2271b1; }
+.clusterlane-ob-group { margin-top: 12px; }
+.clusterlane-ob-group[hidden], .clusterlane-ob-rendered[hidden], .clusterlane-ob-no-groups[hidden], .clusterlane-ob-hub[hidden] { display: none; }
+.clusterlane-ob-group-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.clusterlane-ob-group-head > span { font-size: 12px; font-weight: 700; color: #334155; }
+.clusterlane-ob-group-all { background: none; border: 0; padding: 0; color: #2271b1; font-size: 12px; cursor: pointer; text-decoration: underline; }
+.clusterlane-ob-group-all:hover { color: #135e96; }
+.clusterlane-ob-no-groups { margin-top: 10px; }
+
+/* Settings → Content: read the published pages (0.98.177) */
+.clusterlane-rendered-rec { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; background: #dbeafe; color: #1e40af; font-size: 11px; font-weight: 600; }
+.clusterlane-rendered-log { margin-top: 8px !important; }
+.clusterlane-rendered-log--bad { color: #b45309 !important; }
+
+/* Progress */
+.clusterlane-ob-progress-wrap { margin-top: 20px; }
+.clusterlane-ob-progress-label {
+    font-size: 12px;
+    color: #64748b;
+    text-align: center;
+    margin-top: 8px;
+}
+.clusterlane-ob-spinner-wrap {
+    display: flex;
+    justify-content: center;
+    margin: 28px 0;
+}
+.clusterlane-ob-spinner-wrap .clusterlane-spinner {
+    width: 36px;
+    height: 36px;
+    border-width: 3px;
+}
+
+/* Step dots */
+.clusterlane-ob-steps-indicator {
+    display: flex;
+    justify-content: center;
+    gap: 8px;
+    margin-top: 28px;
+    padding-top: 20px;
+    border-top: 1px solid #f1f5f9;
+}
+.clusterlane-ob-dot {
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #e2e8f0;
+    transition: background .2s, transform .2s;
+}
+.clusterlane-ob-dot--active {
+    background: #2271b1;
+    transform: scale(1.25);
+}
+.clusterlane-ob-dot--done { background: #93c5fd; }
+
+/* ── Dashboard bottom grid ──────────────────────────────────── */
+
+/* Quick Navigation */
+.clusterlane-dash-nav-result {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    padding: 9px 12px;
+    text-decoration: none;
+    color: #1e293b;
+    font-size: 13px;
+    border-bottom: 1px solid #f1f5f9;
+    transition: background .1s;
+}
+.clusterlane-dash-nav-result:last-child { border-bottom: none; }
+.clusterlane-dash-nav-result:hover { background: #f8fafc; }
+.clusterlane-dash-nav-result-label {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    flex: 1;
+    min-width: 0;
+}
+
+/* Needs Attention */
+.clusterlane-dash-cluster-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    padding: 5px 0;
+    border-bottom: 1px solid rgba(0,0,0,.05);
+    text-decoration: none;
+    color: #334155;
+    font-size: 12px;
+    transition: color .1s;
+}
+.clusterlane-dash-cluster-row:last-child { border-bottom: none; }
+.clusterlane-dash-cluster-row:hover { color: #2271b1; }
+.clusterlane-dash-cluster-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
+
+/* Back navigation bar */
+.clusterlane-back-bar {
+    font-size: 12px;
+    color: #64748b;
+    margin-bottom: 4px;
+    padding: 6px 0 2px;
+}
+.clusterlane-back-bar-link {
+    color: #2271b1;
+    text-decoration: none;
+    font-weight: 500;
+}
+.clusterlane-back-bar-link:hover { text-decoration: underline; }
+
+/* TMS empty state */
+.clusterlane-tms-empty-state {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    padding: 48px 24px;
+    text-align: center;
+    color: #475569;
+}
+.clusterlane-tms-empty-icon {
+    font-size: 2.5rem;
+    line-height: 1;
+    margin-bottom: 4px;
+    display: block;
+}
+.clusterlane-tms-empty-state strong {
+    font-size: 1.1rem;
+    color: #1e293b;
+}
+.clusterlane-tms-empty-state p {
+    font-size: 13px;
+    color: #64748b;
+    margin: 0;
+    max-width: 360px;
+}
+
+/* Graph info bar */
+.clusterlane-graph-infobar {
+    display: flex;
+    align-items: center;
+    gap: 0;
+    flex-wrap: wrap;
+    font-size: 11px;
+    color: #64748b;
+    margin-bottom: 8px;
+}
+.clusterlane-graph-infobar > span {
+    padding: 3px 12px;
+    position: relative;
+    white-space: nowrap;
+}
+.clusterlane-graph-infobar > span:first-child { padding-left: 0; }
+.clusterlane-graph-infobar > span:last-child::after { display: none; }
+.clusterlane-graph-infobar > span::after {
+    content: "";
+    position: absolute;
+    right: 0;
+    top: 15%;
+    height: 70%;
+    width: 1px;
+    background: linear-gradient(to bottom, transparent, #e2e8f0 25%, #e2e8f0 75%, transparent);
+}
+.clusterlane-graph-infobar strong { color: #1e293b; }
+.clusterlane-graph-infobar-type { font-weight: 600; color: #475569; }
+.clusterlane-graph-infobar-arch {
+    background: #f1f5f9;
+    border: 1px solid #e2e8f0;
+    border-radius: 999px;
+    padding: 2px 10px !important;
+    color: #475569;
+    font-weight: 500;
+    margin: 0 2px;
+}
+
+/* 0.98.150: the archetype pill and its ? travel together (one item, wraps as one). */
+.clusterlane-graph-infobar > .clusterlane-gib-arch { display: inline-flex; align-items: center; gap: 2px; }
+.clusterlane-graph-infobar > .clusterlane-gib-arch { padding-top: 0; padding-bottom: 0; }
+.clusterlane-ci-detail-header .clusterlane-graph-infobar > .clusterlane-gib-arch { padding-left: 0; }
+
+/* Modal/detail-header infobar — lighter text on dark background */
+.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar, .clusterlane-ci-detail-header .clusterlane-graph-infobar {
+    color: rgba(255,255,255,.55);
+    margin-bottom: 0;
+}
+.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar > span::after, .clusterlane-ci-detail-header .clusterlane-graph-infobar > span::after {
+    background: linear-gradient(to bottom, transparent, rgba(255,255,255,.2) 25%, rgba(255,255,255,.2) 75%, transparent);
+}
+.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar strong, .clusterlane-ci-detail-header .clusterlane-graph-infobar strong { color: rgba(255,255,255,.9); }
+.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar-type, .clusterlane-ci-detail-header .clusterlane-graph-infobar-type { color: rgba(255,255,255,.8); }
+.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar-arch, .clusterlane-ci-detail-header .clusterlane-graph-infobar-arch {
+    background: rgba(255,255,255,.1);
+    border-color: rgba(255,255,255,.2) !important;
+    color: rgba(255,255,255,.8);
+}
+
+/* Graph expand modal */
+.clusterlane-ci-graph-modal-overlay {
+    position: fixed;
+    inset: 0;
+    z-index: 99999;
+    background: rgba(15,23,42,.7);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 24px;
+    box-sizing: border-box;
+}
+.clusterlane-ci-graph-modal-inner {
+    background: #fff;
+    border-radius: 12px;
+    width: 100%;
+    max-width: 1200px;
+    height: 85vh;
+    max-height: 860px;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    box-shadow: 0 24px 64px rgba(0,0,0,.3);
+}
+.clusterlane-ci-graph-modal-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 14px 20px;
+    background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%);
+    flex-shrink: 0;
+}
+.clusterlane-ci-graph-modal-title {
+    font-size: 1rem;
+    font-weight: 700;
+    color: #fff;
+    letter-spacing: -.01em;
+}
+.clusterlane-ci-graph-modal-body {
+    display: flex;
+    flex: 1;
+    min-height: 0;
+    overflow: hidden;
+}
+.clusterlane-ci-graph-modal-canvas {
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    background: #fafbfc;
+}
+.clusterlane-ci-graph-modal-right {
+    width: 300px;
+    flex-shrink: 0;
+    padding: 16px;
+    border-left: 1px solid #e2e8f0;
+    overflow-y: auto;
+    background: #f8fafc;
+}
+
+/* 0.98.199 Article panel next to a cluster graph (Cluster Inspector modal + Dashboard expand) */
+.clusterlane-np { font-size: 13px; color: #1e293b; }
+.clusterlane-np__empty { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 48px 8px 0; color: #94a3b8; font-size: 12.5px; line-height: 1.5; text-align: center; }
+.clusterlane-np__empty .clusterlane-g { width: 28px; height: 28px; opacity: .7; }
+.clusterlane-np__img { display: block; width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 12px; background: #e2e8f0; }
+.clusterlane-np__role { display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; border: 1px solid; border-radius: 999px; padding: 2px 8px; }
+.clusterlane-np__title { margin: 6px 0 12px; font-size: 15px; line-height: 1.35; font-weight: 700; color: #0f172a; overflow-wrap: anywhere; }
+.clusterlane-np__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 6px; margin-bottom: 12px; }
+.clusterlane-np__stat { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 4px 7px; text-align: center; }
+.clusterlane-np__stat b { display: block; font-size: 18px; line-height: 1.2; font-weight: 700; color: #475569; font-variant-numeric: tabular-nums; }
+.clusterlane-np__stat b.is-good { color: #15803d; }
+.clusterlane-np__stat b.is-ok { color: #b45309; }
+.clusterlane-np__stat b.is-low { color: #94a3b8; }
+.clusterlane-np__stat b.is-bad { color: #dc2626; }
+.clusterlane-np__stat span { display: block; font-size: 10.5px; color: #64748b; margin-top: 1px; }
+.clusterlane-np__actions { display: grid; gap: 6px; margin-bottom: 16px; }
+.clusterlane-np__btn { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 10px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; color: #1e293b; font-size: 12.5px; font-weight: 500; text-decoration: none; }
+.clusterlane-np__btn:hover, .clusterlane-np__btn:focus { border-color: #cbd5e1; background: #f1f5f9; color: #0f172a; }
+.clusterlane-np__btn--primary { background: #2563eb; border-color: #2563eb; color: #fff; }
+.clusterlane-np__btn--primary:hover, .clusterlane-np__btn--primary:focus { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
+.clusterlane-np__btn .clusterlane-g { width: 14px; height: 14px; }
+.clusterlane-np__sec { margin-top: 14px; }
+.clusterlane-np__h { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #64748b; }
+.clusterlane-np__n { font-size: 10.5px; font-weight: 600; letter-spacing: 0; color: #475569; background: #e2e8f0; border-radius: 999px; padding: 0 7px; line-height: 17px; }
+.clusterlane-np__list { list-style: none; margin: 0; padding: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
+.clusterlane-np__row { display: flex; align-items: center; margin: 0; border-top: 1px solid #f1f5f9; }
+.clusterlane-np__row:first-child { border-top: 0; }
+.clusterlane-np__row:hover { background: #f8fafc; }
+.clusterlane-np__item { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 7px 4px 7px 10px; border: 0; background: none; text-align: left; font: inherit; font-size: 12.5px; line-height: 1.35; color: #1e293b; cursor: pointer; }
+.clusterlane-np__item:hover .clusterlane-np__name, .clusterlane-np__item:focus-visible .clusterlane-np__name { color: #2563eb; }
+.clusterlane-np__item:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; border-radius: 6px; }
+.clusterlane-np__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.clusterlane-np__name { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
+.clusterlane-np__both { flex-shrink: 0; font-size: 12px; line-height: 1; color: #2563eb; }
+.clusterlane-np__open { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-right: 4px; border-radius: 6px; color: #94a3b8; opacity: 0; }
+.clusterlane-np__row:hover .clusterlane-np__open, .clusterlane-np__open:focus { opacity: 1; }
+.clusterlane-np__open:hover, .clusterlane-np__open:focus { color: #2563eb; background: #eff6ff; }
+.clusterlane-np__open .clusterlane-g { width: 14px; height: 14px; }
+@media (hover: none) { .clusterlane-np__open { opacity: 1; } }
+.clusterlane-np__none { margin: 0; padding: 9px 10px; font-size: 12px; color: #94a3b8; background: #fff; border: 1px dashed #e2e8f0; border-radius: 8px; }
+.clusterlane-np__key { margin: 12px 0 0; font-size: 11px; color: #94a3b8; }
+.clusterlane-np__key .clusterlane-np__both { font-size: 11px; }
+@media (max-width: 782px) { .clusterlane-np__img { display: none; } .clusterlane-np__empty { margin-top: 0; flex-direction: row; justify-content: center; } }
+
+/* Cluster AI Analysis card */
+.clusterlane-cluster-ai-card {
+    border: 1px solid #e2e8f0;
+    border-radius: 10px;
+    overflow: hidden;
+    background: #fff;
+    box-shadow: 0 1px 4px rgba(0,0,0,.05);
+}
+.clusterlane-cluster-ai-card-header {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 16px;
+    background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
+}
+.clusterlane-cluster-ai-card-title {
+    font-size: 13px;
+    font-weight: 600;
+    color: #fff;
+}
+.clusterlane-cluster-ai-card-body {
+    padding: 16px;
+}
+.clusterlane-cluster-ai-section-heading {
+    margin: 0 0 6px;
+    font-size: 11px;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: .05em;
+}
+
+/* Member table */
+
+/* === SUBNAV MOBILE === */
+
+/* === CLUSTER FLOW MAP FILTER BUTTONS === */
+
+/* ===================================================================
+   TOOLTIPS
+   =================================================================== */
+.clusterlane-tip {
+    position: relative;
+    display: inline-flex;
+    align-items: center;
+    vertical-align: middle;
+    margin-left: 3px;
+    cursor: help;
+}
+.clusterlane-tip-icon {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 14px;
+    height: 14px;
+    border-radius: 50%;
+    background: #e2e8f0;
+    color: #64748b;
+    font-size: 9px;
+    font-weight: 700;
+    font-style: normal;
+    line-height: 1;
+    transition: background .12s, color .12s;
+    user-select: none;
+}
+.clusterlane-tip:hover .clusterlane-tip-icon, .clusterlane-tip:focus .clusterlane-tip-icon {
+    background: #3b82f6;
+    color: #fff;
+}
+
+/* ===================================================================
+   HELP PAGE
+   =================================================================== */
+.clusterlane-help-steps {
+    display: flex;
+    flex-direction: column;
+    gap: 0;
+}
+.clusterlane-help-step {
+    display: flex;
+    gap: 20px;
+    align-items: flex-start;
+    padding: 20px 0;
+    border-bottom: 1px solid #f1f5f9;
+}
+.clusterlane-help-step:last-child {
+    border-bottom: none;
+    padding-bottom: 0;
+}
+.clusterlane-help-step:first-child {
+    padding-top: 0;
+}
+.clusterlane-help-step-num {
+    flex-shrink: 0;
+    width: 32px;
+    height: 32px;
+    border-radius: 50%;
+    background: #16223a;
+    color: #fff;
+    font-size: 14px;
+    font-weight: 700;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-top: 2px;
+}
+.clusterlane-help-step-body h3 {
+    margin: 0 0 6px;
+    font-size: 14px;
+    font-weight: 700;
+    color: #1e293b;
+}
+.clusterlane-help-step-body p {
+    margin: 0;
+    font-size: 13px;
+    color: #475569;
+    line-height: 1.6;
+}
+
+/* Reference section */
+.clusterlane-help-reference {
+    padding: 0 !important;
+    overflow: hidden;
+}
+.clusterlane-help-reference-summary {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 16px 24px;
+    cursor: pointer;
+    user-select: none;
+    list-style: none;
+}
+.clusterlane-help-reference-summary::-webkit-details-marker { display: none; }
+.clusterlane-help-reference-summary::after {
+    content: "›";
+    font-size: 16px;
+    color: #94a3b8;
+    transition: transform .15s;
+    margin-left: auto;
+}
+details[open] .clusterlane-help-reference-summary::after {
+    transform: rotate(90deg);
+}
+.clusterlane-help-reference-body {
+    padding: 0 28px 24px;
+    border-top: 1px solid #f1f5f9;
+}
+.clusterlane-help-term {
+    display: grid;
+    grid-template-columns: 180px 1fr;
+    gap: 8px 16px;
+    padding: 10px 0;
+    border-bottom: 1px solid #f8fafc;
+    align-items: baseline;
+}
+.clusterlane-help-term:last-child { border-bottom: none; }
+.clusterlane-help-term dt {
+    font-size: 12px;
+    font-weight: 600;
+    color: #1e293b;
+}
+.clusterlane-help-term dd {
+    margin: 0;
+    font-size: 12px;
+    color: #475569;
+    line-height: 1.55;
+}
+@media (max-width: 600px) {
+    .clusterlane-help-term { grid-template-columns: 1fr; }
+    .clusterlane-help-step { flex-direction: column; gap: 10px; }
+}
+
+/* ------------------------------------------------------------------
+   Block editor compatibility notice
+   Shown in Link Suggest (candidate list + apply modal) and TMS
+   when the source article uses Gutenberg block-serialised content.
+------------------------------------------------------------------ */
+.clusterlane-block-notice {
+    background: #fffbeb;
+    border: 1px solid #f59e0b;
+    border-left: 4px solid #f59e0b;
+    border-radius: 4px;
+    padding: 10px 14px;
+    margin-bottom: 14px;
+    font-size: .82rem;
+    line-height: 1.5;
+    color: #78350f;
+}
+.clusterlane-block-notice strong {
+    color: #92400e;
+}
+
+/* Compact variant used inside the apply modal and inline after TMS apply */
+.clusterlane-block-notice--inline {
+    margin: 8px 0 10px;
+    padding: 8px 12px;
+    font-size: .78rem;
+}
+
+/* vis-network tooltip reset — our nodes use HTMLElement titles with their own
+   styling; strip the default white box + border vis wraps around them. */
+.vis-tooltip {
+    background: transparent !important;
+    border: none !important;
+    box-shadow: none !important;
+    padding: 0 !important;
+    border-radius: 0 !important;
+}
+
+/* ── Improve this cluster ───────────────────────────────────────────── */
+
+/* Prominent action panel wrapper — this is where insight becomes action,
+   so it gets a distinct treatment that stands out from the rest of the
+   detail view. */
+.clusterlane-improve-panel {
+    margin-bottom: 28px;
+    border-radius: 14px;
+    overflow: hidden;
+    background: linear-gradient(180deg, #f0f7ff 0%, #f8fafc 140px);
+    border: 1px solid #d6e4f5;
+    box-shadow: 0 4px 20px -8px rgba(34,113,177,.25);
+}
+.clusterlane-improve-panel-header {
+    padding: 16px 20px;
+    background: linear-gradient(135deg, #1e2535 0%, #2a3550 100%);
+    color: #fff;
+}
+.clusterlane-improve-panel-title {
+    display: flex;
+    align-items: center;
+    gap: 13px;
+}
+.clusterlane-improve-panel-icon {
+    font-size: 22px;
+    line-height: 1;
+    flex-shrink: 0;
+    filter: drop-shadow(0 2px 4px rgba(0,0,0,.3));
+}
+.clusterlane-improve-panel-heading {
+    font-size: 15px;
+    font-weight: 700;
+    letter-spacing: .01em;
+    color: #fff;
+}
+.clusterlane-improve-panel-sub {
+    font-size: 12px;
+    color: #b6c6de;
+    margin-top: 2px;
+    line-height: 1.45;
+}
+.clusterlane-improve-panel-body {
+    padding: 16px 16px 14px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+.clusterlane-improve-block {
+    border: 1px solid #e2e8f0;
+    border-radius: 10px;
+    margin-bottom: 0;
+    overflow: hidden;
+    background: #fff;
+    box-shadow: 0 1px 3px rgba(15,23,41,.05);
+    transition: box-shadow .15s, transform .15s;
+}
+.clusterlane-improve-block:hover {
+    box-shadow: 0 3px 10px rgba(15,23,41,.09);
+}
+.clusterlane-improve-block-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    cursor: pointer;
+    user-select: none;
+    background: #fff;
+    border-bottom: 1px solid transparent;
+    transition: background .15s;
+}
+.clusterlane-improve-block-header:hover { background: #f8fafc; }
+.clusterlane-improve-block-header.is-open { border-bottom-color: #eef2f7; }
+.clusterlane-improve-block-label {
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    font-size: 13.5px;
+    font-weight: 600;
+    color: #1e293b;
+}
+.clusterlane-improve-block-icon {
+    font-size: 17px;
+    line-height: 1;
+    width: 30px;
+    height: 30px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    background: #f1f5f9;
+    border-radius: 8px;
+    flex-shrink: 0;
+}
+.clusterlane-improve-block-meta {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+.clusterlane-improve-count {
+    font-size: 11px;
+    font-weight: 700;
+    padding: 3px 10px;
+    border-radius: 999px;
+}
+.clusterlane-improve-count--gap { background: #fef9c3; color: #854d0e; }
+.clusterlane-improve-chevron {
+    font-size: 13px;
+    color: #94a3b8;
+    transition: transform .2s;
+    display: inline-block;
+}
+.clusterlane-improve-block-header.is-open .clusterlane-improve-chevron { transform: rotate(180deg); }
+.clusterlane-improve-block-body {
+    display: none;
+    padding: 12px 16px 14px;
+}
+.clusterlane-improve-block-body.is-open { display: block; }
+.clusterlane-improve-loading {
+    font-size: 12px;
+    color: #94a3b8;
+    padding: 6px 0;
+}
+
+/* Gap suggestion rows */
+.clusterlane-gap-item {
+    display: flex;
+    align-items: flex-start;
+    justify-content: space-between;
+    gap: 10px;
+    padding: 9px 0;
+    border-bottom: 1px solid #f1f5f9;
+}
+.clusterlane-gap-item:last-child { border-bottom: none; padding-bottom: 0; }
+.clusterlane-gap-item-text {
+    flex: 1;
+    font-size: 12px;
+    color: #1e293b;
+    line-height: 1.5;
+}
+.clusterlane-gap-item-text small {
+    display: block;
+    color: #64748b;
+    font-size: 11px;
+    margin-top: 2px;
+}
+
+/* TMS-block count pill */
+.clusterlane-improve-count--tms { background: #dcfce7; color: #166534; }
+
+/* TMS match rows */
+
+/* AI link suggestions block */
+.clusterlane-improve-count--ailink { background: #ede9fe; color: #5b21b6; }
+
+/* Paragraph field hint (explains the [LINK] marker) */
+.clusterlane-ls-field-hint {
+    margin: 4px 0 10px;
+    font-size: .72rem;
+    color: #64748b;
+    line-height: 1.4;
+}
+.clusterlane-ls-field-hint code {
+    background: #eef2ff;
+    color: #4338ca;
+    padding: 1px 5px;
+    border-radius: 3px;
+    font-size: .68rem;
+    font-weight: 600;
+}
+
+/* ── AI Link modal — context card ─────────────────────────────── */
+.clusterlane-ls-context-card {
+    background: linear-gradient(135deg, #1e2535 0%, #2a3550 100%);
+    border-radius: 10px;
+    padding: 14px 16px;
+    color: #e2e8f0;
+}
+.clusterlane-ls-context-flow {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+}
+.clusterlane-ls-context-node {
+    font-size: .82rem;
+    font-weight: 600;
+    padding: 5px 11px;
+    border-radius: 6px;
+    line-height: 1.3;
+}
+.clusterlane-ls-context-source {
+    background: rgba(255,255,255,.12);
+    color: #fff;
+}
+.clusterlane-ls-context-target {
+    background: #2271b1;
+    color: #fff;
+}
+.clusterlane-ls-context-arrow {
+    font-size: 1.1rem;
+    color: #94a3b8;
+    flex-shrink: 0;
+}
+.clusterlane-ls-context-why {
+    margin-top: 10px;
+    font-size: .78rem;
+    line-height: 1.5;
+    color: #cbd5e1;
+}
+.clusterlane-ls-context-why strong { color: #fff; }
+
+/* ── AI Link modal — variant tabs ─────────────────────────────── */
+.clusterlane-ls-variant-tabs {
+    display: flex;
+    gap: 6px;
+    margin-bottom: 4px;
+    min-width: 0;
+}
+.clusterlane-ls-variant-tab {
+    flex: 1;
+    min-width: 0;
+    padding: 7px 10px;
+    font-size: .78rem;
+    font-weight: 600;
+    color: #64748b;
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 7px;
+    cursor: pointer;
+    transition: all .15s;
+    box-sizing: border-box;
+}
+.clusterlane-ls-variant-tab:hover {
+    border-color: #cbd5e1;
+    color: #334155;
+}
+.clusterlane-ls-variant-tab.is-active {
+    background: #2271b1;
+    border-color: #2271b1;
+    color: #fff;
+}
+
+/* Improve-block call-to-action (empty state before AI is invoked) */
+.clusterlane-improve-cta {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    padding: 4px 2px;
+}
+.clusterlane-improve-cta-text {
+    flex: 1;
+    margin: 0;
+    font-size: 12.5px;
+    color: #475569;
+    line-height: 1.5;
+}
+.clusterlane-improve-cta .clusterlane-action-btn {
+    flex-shrink: 0;
+}
+
+/* AI link — editable target dropdown */
+.clusterlane-ailink-arrow {
+    color: #94a3b8;
+    font-weight: 600;
+}
+
+/* ── Dashboard: article search row ─────────────────────────────────── */
+
+/* ── Dashboard: cluster graph widget ───────────────────────────────── */
+#clusterlane-dash-graph-card {
+    margin-top: 20px;
+}
+.clusterlane-dash-graph-details { padding: 0; }
+.clusterlane-dash-graph-ci-btn {
+    background: #16223a;
+    color: #fff !important;
+    border-color: #16223a;
+    font-size: 13px;
+    font-weight: 600;
+    padding: 6px 16px;
+    border-radius: 8px;
+    letter-spacing: .01em;
+    transition: background 120ms, border-color 120ms;
+}
+.clusterlane-dash-graph-ci-btn:hover {
+    background: #1e3a5f;
+    border-color: #1e3a5f;
+    color: #fff !important;
+    text-decoration: none;
+}
+.clusterlane-dash-graph-details > summary { list-style: none; cursor: pointer; }
+.clusterlane-dash-graph-details > summary::-webkit-details-marker { display: none; }
+.clusterlane-dash-graph-details > div { padding: 0 24px 20px; }
+.clusterlane-dash-graph-controls {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+.clusterlane-dash-graph-controls label {
+    font-size: 12px;
+    color: #64748b;
+    font-weight: 500;
+}
+#clusterlane-dash-graph-select {
+    font-size: 12px;
+    font-weight: 600;
+    color: #1e293b;
+    background: #fff;
+    border: 1.5px solid #cbd5e1;
+    border-radius: 6px;
+    padding: 4px 30px 4px 10px;
+    height: 34px;
+    min-width: 200px;
+    max-width: 320px;
+    cursor: pointer;
+}
+#clusterlane-dash-graph-select:focus {
+    outline: none;
+    border-color: #2271b1;
+    box-shadow: 0 0 0 2px rgba(34,113,177,.15);
+}
+#clusterlane-dash-graph-container {
+    height: 480px;
+    position: relative;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    background: #f8fafc;
+    overflow: hidden;
+}
+#clusterlane-dash-graph-container .vis-network { border-radius: 6px; }
+.clusterlane-dash-graph-hint, .clusterlane-graph-zoom-hint {
+    position: absolute;
+    right: 10px;
+    bottom: 10px;
+    z-index: 5;
+    font-size: 11px;
+    color: #64748b;
+    background: rgba(255,255,255,.9);
+    border: 1px solid #e2e8f0;
+    border-radius: 999px;
+    padding: 4px 10px;
+    pointer-events: none;
+    transition: opacity .2s;
+}
+.is-zoom-active .clusterlane-dash-graph-hint, .is-zoom-active .clusterlane-graph-zoom-hint { opacity: 0; }
+#clusterlane-dash-graph-container.is-zoom-active, #clusterlane-ci-detail-graph.is-zoom-active, #clusterlane-ci-flow-graph.is-zoom-active, #clusterlane-ai-ego-graph.is-zoom-active { outline: 2px solid rgba(34,113,177,.35); outline-offset: -2px; }
+
+/* hint needs a positioned parent */
+#clusterlane-ci-detail-graph, #clusterlane-ci-flow-graph, #clusterlane-ai-ego-graph { position: relative; }
+#clusterlane-dash-graph-notice {
+    padding: 32px 20px;
+    text-align: center;
+    color: #64748b;
+    font-size: 13px;
+    line-height: 1.6;
+}
+@keyframes clusterlaneGraphFlash {
+    0%   { box-shadow: 0 0 0 0 rgba(34,113,177,.45); }
+    100% { box-shadow: 0 0 0 12px rgba(34,113,177,0); }
+}
+.clusterlane-dash-graph-card--flash { animation: clusterlaneGraphFlash .9s ease-out; }
+
+/* ── Dashboard: needs attention, full width ────────────────────────── */
+.clusterlane-dash-cluster-row--graph { cursor: pointer; }
+.clusterlane-dash-cluster-row--graph:hover .clusterlane-dash-cluster-label { text-decoration: underline; }
+
+/* AI analysis now sits last — give it breathing room */
+.clusterlane-site-analysis-card { margin-top: 20px; }
+
+/* ── Collapsible panel cards (Cluster Inspector) ───────────────────── */
+details.clusterlane-collapse { padding: 0 !important; margin-top: 16px; }
+.clusterlane-collapse-head {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+    padding: 16px 24px;
+    cursor: pointer;
+    list-style: none;
+    user-select: none;
+    border-radius: 14px;
+}
+.clusterlane-collapse-head::-webkit-details-marker { display: none; }
+.clusterlane-collapse-head::before {
+    content: "▸";
+    font-size: .9rem;
+    color: #94a3b8;
+    transition: transform 120ms;
+    flex-shrink: 0;
+    width: 12px;
+}
+details.clusterlane-collapse[open] > .clusterlane-collapse-head::before { transform: rotate(90deg); }
+details.clusterlane-collapse[open] > .clusterlane-collapse-head { border-bottom: 1px solid #f1f5f9; border-radius: 14px 14px 0 0; }
+.clusterlane-collapse-head:hover { background: #fafbfc; }
+.clusterlane-collapse-head .clusterlane-ai-section-title { margin: 0; }
+.clusterlane-collapse-count { font-size: .8rem; color: #94a3b8; }
+.clusterlane-collapse-sub { font-size: 11px; color: #94a3b8; margin-left: auto; }
+.clusterlane-collapse-body { padding: 16px 24px 20px; }
+
+/* ── Toolbar strip ─────────────────────────────────────────────────── */
+.clusterlane-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+    padding: 8px 10px;
+    margin-bottom: 14px;
+    background: #f8fafc;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+}
+.clusterlane-toolbar-label {
+    font-size: 11px;
+    font-weight: 600;
+    letter-spacing: .04em;
+    text-transform: uppercase;
+    color: #94a3b8;
+}
+.clusterlane-toolbar-sep { width: 1px; height: 22px; background: #e2e8f0; }
+.clusterlane-toolbar-search { flex: 1 1 220px; max-width: 360px; }
+.clusterlane-toolbar .clusterlane-search-wrap .clusterlane-search-input { height: 32px; box-shadow: none; }
+.clusterlane-toolbar .clusterlane-tms-select { height: 32px !important; padding-top: 0 !important; padding-bottom: 0 !important; }
+
+/* Segmented button group inside toolbar */
+.clusterlane-seg {
+    display: inline-flex;
+    gap: 0;
+    background: #fff;
+    border: 1.5px solid #cbd5e1;
+    border-radius: 6px;
+    overflow: hidden;
+    height: 32px;
+    box-sizing: border-box;
+}
+.clusterlane-seg .clusterlane-seg-btn {
+    border: 0;
+    border-right: 1px solid #e2e8f0;
+    border-radius: 0;
+    background: #fff;
+    color: #475569;
+    font-size: 12px;
+    font-weight: 500;
+    padding: 0 12px;
+    height: 100%;
+    cursor: pointer;
+    transition: background .1s, color .1s;
+}
+.clusterlane-seg .clusterlane-seg-btn:last-child { border-right: 0; }
+.clusterlane-seg .clusterlane-seg-btn:hover { background: #f1f5f9; color: #1e293b; }
+.clusterlane-seg .clusterlane-seg-btn.is-active, .clusterlane-seg .clusterlane-seg-btn.active {
+    background: #16223a;
+    color: #fff;
+    font-weight: 600;
+}
+
+/* Gap suggestion actions (Brief + Draft) */
+.clusterlane-gap-item-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
+
+/* ===================================================
+   STICKY BAR
+   =================================================== */
+#clusterlane-sticky-bar {
+    position: sticky;
+    top: 32px; /* WP admin bar height */
+    z-index: 900;
+    background: #fff;
+    border-bottom: 1px solid #e2e8f0;
+    box-shadow: 0 2px 8px rgba(0,0,0,.06);
+    padding: 0 16px;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    height: 48px;
+    margin: 0 -20px 20px; /* bleed under WP content padding */
+}
+
+/* Search results dropdown */
+#clusterlane-sticky-search-results {
+    position: absolute;
+    top: calc(100% + 4px);
+    left: 0;
+    right: 0;
+    background: #fff;
+    border: 1.5px solid #e2e8f0;
+    border-radius: 8px;
+    box-shadow: 0 8px 24px rgba(0,0,0,.10);
+    z-index: 9999;
+    max-height: 320px;
+    overflow-y: auto;
+}
+
+/* Responsive */
+@media (max-width: 782px) {
+    #clusterlane-sticky-bar {
+        top: 46px; /* Mobile WP admin bar */
+        gap: 8px;
+        padding: 0 10px;
+    }
+}
+
+/* ===================================================
+   STICKY BAR — .clusterlane-stickybar  (v2, 0.98.063)
+   A floating dark dock in the hero's navy, same width and
+   radius as the cards below. One memorable detail: the
+   active tab is marked by a glowing graph node, not an
+   underline. Row 1: brand mark + tabs. Row 2: search + actions.
+   =================================================== */
+.clusterlane-stickybar {
+    --sb-ink:      #16223a;   /* hero navy = clusterlane.app navy */
+    --sb-ink-2:    #1c2b47;   /* row 2 */
+    --sb-line:     rgba(255,255,255,.08);
+    --sb-text:     #9fb0c8;
+    --sb-text-hi:  #f1f5f9;
+    --sb-muted:    #7c8ea8;
+        --sb-node:     #60a5fa;
+
+    /* The outer bar paints the admin background edge to edge, so content
+       scrolling underneath never peeks around the floating dock. */
+    position: sticky;
+    top: 32px;                /* WP admin bar */
+    z-index: 900;
+    margin: 0 -20px 10px;
+    padding: 8px 20px 12px;
+    background: #f0f0f1;      /* wp-admin body */
+}
+
+/* The dock itself */
+.clusterlane-stickybar__inner {
+    position: relative;
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    background: var(--sb-ink);
+    border-radius: 14px;
+    box-shadow: 0 6px 18px rgba(15,23,42,.16);
+    transition: box-shadow 200ms ease;
+}
+.clusterlane-stickybar.is-stuck .clusterlane-stickybar__inner { box-shadow: 0 10px 28px rgba(15,23,42,.28); }
+
+/* ---- Row 1: brand + tabs -------------------------------------- */
+.clusterlane-stickybar__inner > .clusterlane-stickybar__nav { flex: 0 0 100%; width: 100%; }
+.clusterlane-stickybar__nav {
+    display: flex;
+    align-items: stretch;
+    height: 46px;
+    padding: 0 14px 0 8px;
+    border-bottom: 1px solid var(--sb-line);
+    overflow: hidden;          /* tabs that don't fit go into More (0.98.186) */
+}
+.clusterlane-stickybar__brand {
+    display: inline-flex;
+    align-items: center;
+    padding: 0 10px 0 4px;
+    margin-right: 6px;
+    border-right: 1px solid var(--sb-line);
+    text-decoration: none;
+    flex-shrink: 0;
+}
+.clusterlane-stickybar__brand-mark {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 28px;
+    height: 28px;
+    border-radius: 8px;
+}
+.clusterlane-stickybar__brand-mark .clusterlane-brand-mark, .clusterlane-stickybar__brand-mark .clusterlane-brand-logo { display: block; }
+.clusterlane-hero-mark { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex-shrink: 0; }
+.clusterlane-hero-mark .clusterlane-brand-mark, .clusterlane-hero-mark .clusterlane-brand-logo { display: block; }
+.clusterlane-brand-logo { object-fit: contain; border-radius: 6px; }
+.clusterlane-stickybar__brand:focus-visible { outline: 2px solid var(--sb-node); outline-offset: 2px; border-radius: 8px; }
+.clusterlane-stickybar__tab {
+    position: relative;
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 0 12px;
+    margin: 7px 1px;
+    border-radius: 8px;
+    font-size: 12.5px;
+    font-weight: 500;
+    color: var(--sb-text);
+    text-decoration: none;
+    white-space: nowrap;
+    transition: color 120ms, background 120ms;
+}
+.clusterlane-stickybar__tab .dashicons { font-size: 15px; width: 15px; height: 15px; opacity: .7; }
+.clusterlane-stickybar__tab:hover, .clusterlane-stickybar__tab:focus { color: var(--sb-text-hi); background: rgba(255,255,255,.05); text-decoration: none; box-shadow: none; }
+.clusterlane-stickybar__tab:focus-visible { outline: 2px solid var(--sb-node); outline-offset: -2px; }
+.clusterlane-stickybar__tab--active, .clusterlane-stickybar__tab--active:hover {
+    color: var(--sb-text-hi);
+    font-weight: 600;
+    background: rgba(96,165,250,.12);
+}
+.clusterlane-stickybar__tab--active .dashicons { opacity: 1; color: var(--sb-node); }
+
+/* The node: active page = the lit node in the graph */
+.clusterlane-stickybar__tab--active::after {
+    content: "";
+    position: absolute;
+    left: 50%;
+    bottom: -8px;
+    width: 7px;
+    height: 7px;
+    margin-left: -3.5px;
+    border-radius: 50%;
+    background: var(--sb-node);
+    box-shadow: 0 0 0 3px rgba(96,165,250,.20), 0 0 12px 2px rgba(96,165,250,.55);
+}
+
+/* Groups (0.98.186): main pages · tools · admin · help, as in the left menu */
+.clusterlane-stickybar__tab--group { margin-left: 13px; }
+.clusterlane-stickybar__tab--group::before {
+    content: ""; position: absolute; left: -8px; top: 8px; bottom: 8px; width: 1px; background: var(--sb-line);
+}
+/* More: the tabs that don't fit */
+.clusterlane-stickybar__more { display: flex; align-items: stretch; margin-left: auto; padding-left: 6px; margin-right: 6px; }
+/* Bold (active) and regular labels take the same width, so the row never reflows
+   between pages (0.98.192): an invisible bold copy reserves the space. */
+.clusterlane-stickybar__tab-label { display: inline-flex; flex-direction: column; }
+.clusterlane-stickybar__tab-label::after { content: attr(data-label); height: 0; overflow: hidden; visibility: hidden; font-weight: 600; pointer-events: none; user-select: none; }
+.clusterlane-stickybar__more-btn.clusterlane-stickybar__tab--active-in::after {
+    content: ""; position: absolute; left: 50%; bottom: -8px; width: 7px; height: 7px; margin-left: -3.5px; border-radius: 50%;
+    background: var(--sb-node); box-shadow: 0 0 0 3px rgba(96,165,250,.20), 0 0 12px 2px rgba(96,165,250,.55);
+}
+.clusterlane-stickybar__more[hidden] { display: none; }
+.clusterlane-stickybar__more-btn { border: 0; background: transparent; cursor: pointer; font-family: inherit; }
+.clusterlane-stickybar__more-btn.clusterlane-stickybar__tab--active-in { color: var(--sb-text-hi); background: rgba(96,165,250,.12); }
+.clusterlane-stickybar__more-menu {
+    position: fixed; z-index: 10000; min-width: 210px;
+    display: flex; flex-direction: column; padding: 6px;
+    background: #fff; border: 1px solid #e2e8f0; border-radius: 10px;
+    box-shadow: 0 14px 34px rgba(15,23,42,.22);
+}
+.clusterlane-stickybar__more-menu[hidden] { display: none; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab { margin: 0; padding: 9px 10px; color: #1e293b; flex-direction: row; justify-content: flex-start; gap: 8px; max-width: none; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab:hover, .clusterlane-stickybar__more-menu .clusterlane-stickybar__tab:focus { color: #0f172a; background: #f1f5f9; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab .dashicons { opacity: .6; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab--active { color: #1d4ed8; background: #eff6ff; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab::after, .clusterlane-stickybar__more-menu .clusterlane-stickybar__tab::before { display: none; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab--group { margin-left: 0; margin-top: 5px; border-top: 1px solid #f1f5f9; padding-top: 12px; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab-label::after { display: none; }
+.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab-label { display: inline !important; font-size: 13px !important; white-space: nowrap; text-align: left; line-height: 1.3; }
+
+/* ---- Row 2: search + actions ---------------------------------- */
+.clusterlane-stickybar__search-group {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex: 1 1 auto;
+    height: 48px;
+    padding: 0 14px;
+    background: var(--sb-ink-2);
+    border-bottom-left-radius: 14px;
+}
+.clusterlane-stickybar__actions {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    height: 48px;
+    padding: 0 14px 0 0;
+    margin-left: auto;
+    flex-shrink: 0;
+    background: var(--sb-ink-2);
+    border-bottom-right-radius: 14px;
+}
+
+/* Row 2 is two flex items; paint the gap between them too */
+.clusterlane-stickybar__inner { background: linear-gradient(to bottom, var(--sb-ink) 46px, var(--sb-ink-2) 46px); }
+.clusterlane-stickybar__search-wrap { position: relative; display: flex; align-items: center; }
+.clusterlane-stickybar__search-icon {
+    position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
+    color: var(--sb-muted); font-size: 14px; width: 14px; height: 14px; pointer-events: none;
+}
+#clusterlane-stickybar .clusterlane-stickybar__search-input, #clusterlane-stickybar input[type="search"].clusterlane-stickybar__search-input, #clusterlane-stickybar .clusterlane-stickybar__cluster-select, #clusterlane-stickybar select.clusterlane-stickybar__cluster-select {
+    height: 32px !important;
+    min-height: 0 !important;  /* wp-admin forms set 40px */
+    border: 1px solid rgba(255,255,255,.10) !important;
+    border-radius: 8px !important;
+    font-size: 13px !important;
+    color: var(--sb-text-hi) !important;
+    background-color: rgba(255,255,255,.06) !important;
+    box-shadow: none !important;
+    box-sizing: border-box !important;
+    margin: 0 !important;
+    transition: border-color 120ms, background-color 120ms, box-shadow 120ms;
+}
+#clusterlane-stickybar .clusterlane-stickybar__search-input, #clusterlane-stickybar input[type="search"].clusterlane-stickybar__search-input {
+    width: 230px !important;
+    padding: 0 10px 0 32px !important;
+    line-height: 30px !important;
+}
+#clusterlane-stickybar .clusterlane-stickybar__search-input::placeholder { color: var(--sb-muted); opacity: 1; }
+#clusterlane-stickybar .clusterlane-stickybar__cluster-select, #clusterlane-stickybar select.clusterlane-stickybar__cluster-select {
+    min-width: 210px !important;
+    padding: 0 30px 0 32px !important;
+    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237c8ea8'/%3E%3C/svg%3E") !important;
+    background-repeat: no-repeat !important;
+    background-position: right 11px center !important;
+    background-size: 10px 6px !important;   /* wp-admin scales select arrows to 16px */
+    line-height: 30px !important;
+    appearance: none !important;
+    -webkit-appearance: none !important;
+    cursor: pointer !important;
+}
+
+/* Native option lists open on a light surface — keep them readable */
+#clusterlane-stickybar .clusterlane-stickybar__cluster-select option { color: #1e293b; background: #fff; }
+#clusterlane-stickybar .clusterlane-stickybar__search-input:hover, #clusterlane-stickybar .clusterlane-stickybar__cluster-select:hover { border-color: rgba(255,255,255,.20) !important; }
+#clusterlane-stickybar .clusterlane-stickybar__search-input:focus, #clusterlane-stickybar input[type="search"].clusterlane-stickybar__search-input:focus, #clusterlane-stickybar .clusterlane-stickybar__cluster-select:focus, #clusterlane-stickybar select.clusterlane-stickybar__cluster-select:focus {
+    outline: none !important;
+    border-color: var(--sb-node) !important;
+    background-color: rgba(255,255,255,.10) !important;
+    box-shadow: 0 0 0 3px rgba(96,165,250,.22) !important;
+}
+
+/* Article search results: a light panel under the dark bar */
+.clusterlane-stickybar__dropdown {
+    position: absolute;
+    top: calc(100% + 8px);
+    left: 0;
+    min-width: 100%;
+    width: 340px;
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 10px;
+    box-shadow: 0 14px 34px rgba(15,23,42,.22);
+    z-index: 9999;
+    max-height: 340px;
+    overflow-y: auto;
+    list-style: none;
+    margin: 0;
+    padding: 4px 0;
+}
+.clusterlane-stickybar__dropdown li {
+    display: flex; align-items: center; gap: 10px;
+    padding: 8px 14px; cursor: pointer;
+    font-size: 13px; color: #1e293b;
+    border-bottom: 1px solid #f1f5f9;
+    transition: background 100ms;
+}
+.clusterlane-stickybar__dropdown li:last-child { border-bottom: 0; }
+.clusterlane-stickybar__dropdown li:hover { background: #f1f5f9; }
+.clusterlane-stickybar__action-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 32px;
+    padding: 0 13px;
+    border: 1px solid rgba(255,255,255,.12);
+    border-radius: 8px;
+    background: transparent;
+    color: var(--sb-text);
+    font-size: 12.5px;
+    font-weight: 500;
+    cursor: pointer;
+    white-space: nowrap;
+    transition: background 120ms, color 120ms, border-color 120ms;
+}
+.clusterlane-stickybar__action-btn .dashicons { font-size: 14px; width: 14px; height: 14px; }
+.clusterlane-stickybar__action-btn:hover { background: rgba(255,255,255,.07); color: var(--sb-text-hi); border-color: rgba(255,255,255,.22); }
+.clusterlane-stickybar__action-btn:focus-visible { outline: 2px solid var(--sb-node); outline-offset: 2px; }
+.clusterlane-stickybar__action-btn:disabled { opacity: .55; cursor: progress; }
+.clusterlane-stickybar__action-btn--primary {
+    background: #fff;
+    border-color: #fff;
+    color: var(--sb-ink);
+    font-weight: 600;
+}
+.clusterlane-stickybar__action-btn--primary:hover { background: #e2e8f0; border-color: #e2e8f0; color: var(--sb-ink); }
+
+/* PRO badge sits on dark here */
+.clusterlane-stickybar .clusterlane-pro-badge--tab { box-shadow: 0 0 0 1px rgba(255,255,255,.15); }
+
+/* Toast */
+.clusterlane-stickybar__toast {
+    position: absolute;
+    bottom: -30px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: var(--sb-ink);
+    color: var(--sb-text-hi);
+    font-size: 12px;
+    padding: 7px 14px;
+    border-radius: 8px;
+    box-shadow: 0 8px 20px rgba(15,23,42,.25);
+    white-space: nowrap;
+    pointer-events: none;
+    z-index: 1000;
+}
+@media (prefers-reduced-motion: reduce) {
+    .clusterlane-stickybar, .clusterlane-stickybar__tab, .clusterlane-stickybar__action-btn { transition: none; }
+}
+
+/* Responsive */
+@media (max-width: 782px) {
+    /* Phones (0.98.186): the bar scrolls away with the page instead of keeping
+       ~200 of 844 px, and every tab says what it is under its icon. */
+    .clusterlane-stickybar { position: relative; top: 0; margin: 0 -10px 8px; padding: 6px 10px 10px; }
+    .clusterlane-stickybar__inner { border-radius: 12px; background: linear-gradient(to bottom, var(--sb-ink) 54px, var(--sb-ink-2) 54px); }
+    .clusterlane-stickybar__nav { height: 54px; padding: 0 4px; }
+    .clusterlane-stickybar__brand { padding: 0 6px 0 2px; margin-right: 2px; }
+    .clusterlane-stickybar__tab { flex-direction: column; justify-content: center; gap: 3px; padding: 0 6px; margin: 5px 0; max-width: 76px; }
+    .clusterlane-stickybar__tab-label { font-size: 10px; line-height: 1.15; white-space: normal; text-align: center; }
+    .clusterlane-stickybar__tab--group { margin-left: 9px; }
+    .clusterlane-stickybar__tab--group::before { left: -5px; top: 12px; bottom: 12px; }
+    .clusterlane-stickybar__nav .clusterlane-pro-badge--tab { display: none; }
+    .clusterlane-stickybar__tab--active::after { bottom: -6px; }
+    .clusterlane-stickybar__search-group { padding: 0 10px; gap: 6px; flex: 1 1 auto; border-bottom-left-radius: 12px; }
+    .clusterlane-stickybar__search-wrap { flex: 1 1 auto; min-width: 0; }
+    #clusterlane-stickybar .clusterlane-stickybar__search-input, #clusterlane-stickybar input[type="search"].clusterlane-stickybar__search-input { width: 100% !important; }
+    #clusterlane-stickybar .clusterlane-stickybar__cluster-select, #clusterlane-stickybar select.clusterlane-stickybar__cluster-select { min-width: 0 !important; width: 100% !important; }
+    .clusterlane-stickybar__actions { padding: 0 10px 0 0; border-bottom-right-radius: 12px; }
+    .clusterlane-stickybar__action-btn--primary .clusterlane-stickybar__action-label { display: inline; }
+    .clusterlane-stickybar__action-btn { padding: 0 9px; min-width: 34px; justify-content: center; }
+}
+
+/* ===================================================
+   PRINT / PDF — Site Intelligence Analysis
+   =================================================== */
+@media print {
+    #wpadminbar, #adminmenuwrap, #adminmenuback, #wpfooter, .clusterlane-stickybar, .clusterlane-sa-btn-wrap, #clusterlane-site-analysis-regen, #clusterlane-sa-print, .clusterlane-action-btn, .clusterlane-sa-ai-badge, .update-nag, .notice, .error { display: none !important; }
+    html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; }
+    #wpcontent, #wpbody, #wpbody-content { margin: 0 !important; padding: 0 !important; float: none !important; }
+    .wrap { margin: 0 !important; padding: 12mm 14mm !important; }
+    #clusterlane-site-analysis-card { display: block !important; }
+    #clusterlane-site-analysis-card > summary { display: none !important; }
+    #clusterlane-site-analysis-body { display: block !important; }
+    .clusterlane-sa-strength, .clusterlane-sa-opp, .clusterlane-sa-action { break-inside: avoid; }
+    .clusterlane-sa-strength-grid, .clusterlane-sa-opp-grid { grid-template-columns: repeat(3, 1fr) !important; }
+    .clusterlane-sa-action-list { grid-template-columns: 1fr !important; }
+    .clusterlane-site-analysis-header { margin-bottom: 6mm; }
+    .clusterlane-site-analysis-title { font-size: 18pt !important; }
+    #clusterlane-site-analysis-result::after {
+        content: "Generated by Clusterlane · " attr(data-generated);
+        display: block;
+        margin-top: 8mm;
+        font-size: 9pt;
+        color: #94a3b8;
+        border-top: 1px solid #e2e8f0;
+        padding-top: 4mm;
+    }
+}
+
+/* =========================================================
+   Help page — Admin & Support panel (manage_clusterlane only)
+   ========================================================= */
+.clusterlane-support-panel {
+    margin-top: 24px;
+    background: #fff;
+    border: 1px solid #e2e8f0;
+    border-radius: 14px;
+    overflow: hidden;
+    box-shadow: 0 4px 18px rgba(15, 23, 42, .06);
+}
+.clusterlane-support-header {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    gap: 14px;
+    padding: 18px 22px;
+    background: linear-gradient(135deg, #1e2535 0%, #2a3550 100%);
+    color: #fff;
+}
+.clusterlane-support-title { display: flex; align-items: center; gap: 13px; }
+.clusterlane-support-icon {
+    display: inline-flex; align-items: center; justify-content: center;
+    width: 40px; height: 40px; border-radius: 10px;
+    background: rgba(255, 255, 255, .1); font-size: 20px;
+}
+.clusterlane-support-title h2 { margin: 0; color: #fff; font-size: 16px; font-weight: 700; }
+.clusterlane-support-title p { margin: 2px 0 0; color: #94a3b8; font-size: 12px; }
+.clusterlane-support-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.clusterlane-support-badge {
+    padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
+}
+.clusterlane-support-badge--ok { background: rgba(16, 185, 129, .18); color: #6ee7b7; }
+.clusterlane-support-badge--warn { background: rgba(245, 158, 11, .18); color: #fcd34d; }
+.clusterlane-support-btn {
+    height: 34px; padding: 0 14px; border-radius: 8px; cursor: pointer;
+    border: 1px solid rgba(255, 255, 255, .25); background: transparent;
+    color: #fff; font-size: 13px; font-weight: 600;
+}
+.clusterlane-support-btn:hover { background: rgba(255, 255, 255, .08); }
+.clusterlane-support-btn--primary { background: #fff; color: #0f172a; border-color: #fff; }
+.clusterlane-support-btn--primary:hover { background: #e2e8f0; }
+.clusterlane-support-btn:disabled { opacity: .8; cursor: default; }
+.clusterlane-support-body { padding: 20px 22px 24px; }
+.clusterlane-support-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+    gap: 14px;
+}
+.clusterlane-support-grid + .clusterlane-support-grid { margin-top: 14px; }
+.clusterlane-support-card {
+    border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 16px; background: #f8fafc;
+    min-width: 0;
+}
+.clusterlane-support-card--wide { grid-column: 1 / -1; }
+.clusterlane-support-card--full { grid-column: 1 / -1; }
+.clusterlane-support-card--full .clusterlane-support-table { width: 100%; }
+.clusterlane-support-card--full .clusterlane-support-table th { width: 240px; }
+.clusterlane-support-card--wide .clusterlane-support-table { columns: 2 320px; display: block; }
+.clusterlane-support-card--wide .clusterlane-support-table tr { display: flex; break-inside: avoid; }
+.clusterlane-support-card--wide .clusterlane-support-table th { flex: 1 1 auto; }
+.clusterlane-support-card h3 {
+    margin: 0 0 10px; font-size: 13px; font-weight: 700; color: #0f172a;
+    display: flex; align-items: center; gap: 6px;
+}
+.clusterlane-support-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
+.clusterlane-support-table th {
+    text-align: left; font-weight: 500; color: #64748b; padding: 5px 10px 5px 0;
+    vertical-align: top; white-space: nowrap;
+}
+.clusterlane-support-table td { color: #0f172a; padding: 5px 0; word-break: break-word; }
+.clusterlane-support-table tr + tr th, .clusterlane-support-table tr + tr td { border-top: 1px solid #e2e8f0; }
+.clusterlane-support-row--warn td { color: #92400e; font-weight: 600; }
+.clusterlane-support-dot {
+    display: inline-block; width: 8px; height: 8px; border-radius: 50%;
+    margin-right: 6px; vertical-align: 1px;
+}
+.clusterlane-support-dot--ok { background: #10b981; }
+.clusterlane-support-dot--warn { background: #f59e0b; }
+.clusterlane-support-note { margin: 8px 0 6px; font-size: 12.5px; color: #475569; }
+.clusterlane-support-code {
+    margin: 0 0 8px; padding: 10px 12px; border-radius: 8px;
+    background: #0f172a; color: #e2e8f0; font-size: 12px; line-height: 1.5;
+    overflow-x: auto; white-space: pre;
+}
+.clusterlane-support-list { margin: 0; padding-left: 18px; font-size: 12.5px; color: #334155; }
+.clusterlane-support-list li { margin-bottom: 6px; }
+.clusterlane-support-report {
+    position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0;
+}
+@media (max-width: 600px) {
+    .clusterlane-support-header { padding: 16px; }
+    .clusterlane-support-body { padding: 16px; }
+    .clusterlane-support-grid { grid-template-columns: 1fr; }
+    .clusterlane-support-table th { white-space: normal; }
+    .clusterlane-support-card--full .clusterlane-support-table th { width: 38%; }
+}
+
+/* =========================================================
+   Network Admin — Clusterlane Network overview
+   ========================================================= */
+.clusterlane-network-btn { display: inline-flex; align-items: center; text-decoration: none; }
+.clusterlane-network-btn:focus { box-shadow: none; }
+.clusterlane-network-table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; }
+.clusterlane-network-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; }
+.clusterlane-network-table th {
+    text-align: left; padding: 10px 12px; background: #f8fafc; color: #64748b;
+    font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em;
+    border-bottom: 1px solid #e2e8f0; white-space: nowrap;
+}
+.clusterlane-network-table td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; vertical-align: top; color: #0f172a; }
+.clusterlane-network-table tr:last-child td { border-bottom: 0; }
+.clusterlane-network-row--inactive td { color: #94a3b8; }
+.clusterlane-network-meta { display: block; color: #94a3b8; font-size: 12px; margin-top: 2px; }
+.clusterlane-network-pill {
+    display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600;
+    background: #eff6ff; color: #1d4ed8;
+}
+.clusterlane-network-pill--off { background: #f1f5f9; color: #94a3b8; }
+.clusterlane-network-actions { white-space: nowrap; }
+.clusterlane-network-actions a { margin-right: 10px; font-weight: 600; text-decoration: none; }
+.clusterlane-network-pager { margin-top: 14px; }
+.clusterlane-network-pager .page-numbers {
+    display: inline-block; min-width: 28px; padding: 4px 8px; margin-right: 4px; text-align: center;
+    border: 1px solid #e2e8f0; border-radius: 6px; text-decoration: none; color: #0f172a;
+}
+.clusterlane-network-pager .page-numbers.current { background: #0f172a; color: #fff; border-color: #0f172a; }
+
+/* =========================================================
+   Network AI key — Network Admin card + site Settings notice
+   ========================================================= */
+.clusterlane-network-ai { margin-bottom: 16px; }
+.clusterlane-network-ai-grid {
+    display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin: 10px 0;
+}
+.clusterlane-network-ai-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: #475569; }
+.clusterlane-network-ai-grid select, .clusterlane-network-ai-grid input { height: 36px; line-height: 20px; border: 1.5px solid #e2e8f0; border-radius: 8px; padding: 0 10px; max-width: 100%; }
+.clusterlane-network-ai-key { grid-column: span 2; }
+@media (max-width: 600px) {
+    .clusterlane-network-ai-key { grid-column: auto; }
+}
+.clusterlane-network-ai-policy { margin: 10px 0 12px; border: 0; padding: 0; }
+.clusterlane-network-ai-policy legend { font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 6px; }
+.clusterlane-network-ai-policy label { display: block; font-size: 13px; margin-bottom: 4px; }
+.clusterlane-network-ai-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.clusterlane-support-btn--dark { background: #0f172a; color: #fff; border-color: #0f172a; }
+.clusterlane-support-btn--dark:hover { background: #1e293b; }
+.clusterlane-support-btn--light { background: #fff; color: #0f172a; border-color: #cbd5e1; }
+.clusterlane-support-btn--light:hover { background: #f1f5f9; }
+.clusterlane-network-ai-saved { margin: 0 0 8px; color: #15803d; font-weight: 600; font-size: 13px; }
+.clusterlane-network-ai-notice {
+    margin: 4px 0 12px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
+    background: #eff6ff; border-left: 4px solid #3b82f6; color: #1e3a8a;
+}
+.clusterlane-network-ai-notice--locked { background: #f8fafc; border-left-color: #64748b; color: #334155; }
+.clusterlane-ai-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
+.clusterlane-ai-fieldset[disabled] { opacity: .6; }
+
+/* =========================================================
+   AI usage & limits (Settings) + network limit field
+   ========================================================= */
+.clusterlane-ai-usage { margin-top: 18px; padding-top: 14px; border-top: 1px solid #e2e8f0; }
+.clusterlane-ai-usage h3 { margin: 0 0 4px; font-size: 14px; }
+.clusterlane-ai-usage-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 12px 0; }
+.clusterlane-ai-usage-stats > div { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; }
+.clusterlane-ai-usage-stats strong { display: block; font-size: 18px; color: #0f172a; }
+.clusterlane-ai-usage-stats span { font-size: 12px; color: #64748b; }
+.clusterlane-ai-usage-table { border-collapse: collapse; margin: 6px 0 10px; font-size: 13px; min-width: 320px; }
+.clusterlane-ai-usage-table th, .clusterlane-ai-usage-table td { text-align: left; padding: 5px 16px 5px 0; border-bottom: 1px solid #f1f5f9; }
+.clusterlane-ai-usage-table th { color: #64748b; font-weight: 600; font-size: 12px; }
+.clusterlane-network-ai-limit { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; font-size: 12px; font-weight: 600; color: #475569; max-width: 520px; }
+.clusterlane-network-ai-limit input { width: 120px; height: 36px; border: 1.5px solid #e2e8f0; border-radius: 8px; padding: 0 10px; }
+.clusterlane-network-ai-limit .clusterlane-support-note { font-weight: 400; margin: 0; }
+
+/* =========================================================
+   Tooltips v2 — shared floating popover (admin/js/clusterlane-tips.js)
+   ========================================================= */
+.clusterlane-tip { margin-left: 4px; outline: none; }
+.clusterlane-tip:focus-visible .clusterlane-tip-icon { box-shadow: 0 0 0 2px #93c5fd; }
+.clusterlane-tip-pop {
+    position: absolute; z-index: 1000010; display: none;
+    width: max-content; max-width: 300px;
+    padding: 10px 12px; border-radius: 8px;
+    background: #1e293b; color: #e2e8f0;
+    font-size: 12px; line-height: 1.5; font-weight: 400; text-align: left; white-space: normal;
+    box-shadow: 0 8px 24px rgba(15, 23, 42, .28);
+}
+.clusterlane-tip-pop.is-open { display: block; }
+.clusterlane-tip-pop::after {
+    content: ""; position: absolute; left: var(--clusterlane-tip-arrow, 50%); transform: translateX(-50%);
+    border: 6px solid transparent; top: 100%; border-top-color: #1e293b;
+}
+.clusterlane-tip-pop.is-below::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: #1e293b; }
+.clusterlane-tip-pop-title { display: block; color: #fff; font-size: 12.5px; margin-bottom: 4px; }
+.clusterlane-tip-pop-text { display: block; }
+.clusterlane-tip-pop-row { display: block; margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.1); }
+.clusterlane-tip-pop-row b { color: #93c5fd; font-weight: 600; }
+
+/* Icons on dark backgrounds (heroes, dark info bars) */
+.clusterlane-hero .clusterlane-tip-icon, .clusterlane-ci-hero .clusterlane-tip-icon, .clusterlane-page-hero .clusterlane-tip-icon, .clusterlane-graph-infobar .clusterlane-tip-icon { background: rgba(255,255,255,.18); color: #e2e8f0; }
+.clusterlane-hero .clusterlane-tip:hover .clusterlane-tip-icon, .clusterlane-ci-hero .clusterlane-tip:hover .clusterlane-tip-icon, .clusterlane-graph-infobar .clusterlane-tip:hover .clusterlane-tip-icon { background: #3b82f6; color: #fff; }
+
+/* Help page term reference */
+.clusterlane-help-term-good, .clusterlane-help-term-fix { display: inline-block; margin-top: 4px; font-size: 12.5px; color: #475569; }
+.clusterlane-help-term-good strong { color: #15803d; }
+.clusterlane-help-term-fix strong { color: #1d4ed8; }
+
+/* =========================================================
+   Editions — Pro badge, example banner, Pro notes (Free edition)
+   ========================================================= */
+.clusterlane-pro-badge {
+    display: inline-block; padding: 1px 7px; border-radius: 999px;
+    background: linear-gradient(135deg, #7c3aed, #2563eb); color: #fff;
+    font-size: 10px; font-weight: 700; letter-spacing: .04em; line-height: 16px; vertical-align: middle;
+}
+.clusterlane-pro-badge--tab { margin-left: 5px; font-size: 9px; padding: 0 6px; }
+.clusterlane-pro-example-banner {
+    display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
+    margin: 0 0 12px; padding: 9px 12px; border-radius: 8px;
+    background: #f5f3ff; border: 1px dashed #a78bfa; color: #4c1d95; font-size: 12.5px; line-height: 1.45;
+}
+.clusterlane-pro-example-text { flex: 1 1 260px; }
+.clusterlane-pro-example-link { font-weight: 600; color: #6d28d9; text-decoration: none; white-space: nowrap; }
+.clusterlane-pro-example-link:hover { text-decoration: underline; }
+.clusterlane-pro-example button[disabled], .clusterlane-pro-example select[disabled] { cursor: not-allowed; }
+.clusterlane-pro-example-pair { font-size: 13px; margin: 6px 0 10px; color: #334155; }
+.clusterlane-pro-example-variants { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin-bottom: 12px; }
+.clusterlane-pro-example-variant { border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; background: #fff; }
+.clusterlane-pro-example-variant p { margin: 6px 0 0; font-size: 13px; color: #334155; line-height: 1.5; }
+.clusterlane-pro-example-variant-head { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: .03em; }
+.clusterlane-pro-example-pos { font-weight: 600; color: #6d28d9; text-transform: none; }
+.clusterlane-pro-note {
+    display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border-radius: 10px;
+    background: #f8fafc; border: 1px solid #e2e8f0;
+}
+.clusterlane-pro-note strong { display: block; font-size: 13px; color: #0f172a; }
+.clusterlane-pro-note p { margin: 2px 0 0; font-size: 12.5px; color: #475569; }
+.clusterlane-pro-note .clusterlane-pro-example-link { margin-left: auto; align-self: center; }
+
+/* =========================================================
+   Inspector heads (0.98.064) — same navy + radius as the dock.
+   Article Inspector: one head for search → then the article
+   itself becomes the headline; its featured image fades in
+   from the right. Cluster Inspector: restyled hero.
+   ========================================================= */
+.clusterlane-inspector-head {
+    --ih-ink: #16223a;
+    --ih-ink-2: #22314f;
+    --ih-text: #9fb0c8;
+    --ih-text-hi: #f1f5f9;
+    --ih-muted: #7c8ea8;
+    --ih-node: #60a5fa;
+    position: relative;
+    overflow: visible;
+    background: linear-gradient(135deg, var(--ih-ink) 0%, var(--ih-ink-2) 100%);
+    border-radius: 14px;
+    padding: 18px 24px 22px;
+    color: var(--ih-text-hi);
+    box-shadow: 0 10px 28px rgba(15,23,42,.18);
+    isolation: isolate;
+}
+.clusterlane-inspector-head__mark {
+    display: inline-flex; align-items: center; justify-content: center;
+    width: 34px; height: 34px; flex-shrink: 0;
+    border-radius: 10px;
+    background: linear-gradient(135deg, #22314f, #16223a);
+    box-shadow: 0 0 0 1px rgba(255,255,255,.08) inset;
+}
+
+/* Row: mark + page name + search */
+.clusterlane-inspector-head__bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.clusterlane-inspector-head__page { font-size: 13px; font-weight: 600; color: var(--ih-text); }
+.clusterlane-inspector-head__search { position: relative; margin-left: auto; width: min(420px, 100%); }
+.clusterlane-inspector-head__search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ih-muted); pointer-events: none; }
+#clusterlane-ai-head .clusterlane-inspector-head__search-input {
+    width: 100% !important; height: 38px !important; min-height: 0 !important; margin: 0 !important;
+    padding: 0 12px 0 36px !important;
+    border: 1px solid rgba(255,255,255,.12) !important; border-radius: 10px !important;
+    background: rgba(255,255,255,.07) !important; color: var(--ih-text-hi) !important;
+    font-size: 14px !important; box-shadow: none !important;
+    transition: border-color 120ms, background 120ms, box-shadow 120ms;
+}
+#clusterlane-ai-head .clusterlane-inspector-head__search-input::placeholder { color: var(--ih-muted); opacity: 1; }
+#clusterlane-ai-head .clusterlane-inspector-head__search-input:focus {
+    outline: none !important; border-color: var(--ih-node) !important;
+    background: rgba(255,255,255,.11) !important; box-shadow: 0 0 0 3px rgba(96,165,250,.22) !important;
+}
+
+/* Results: a light panel over the page */
+.clusterlane-inspector-head__results {
+    position: absolute !important; top: calc(100% + 6px); left: 0; right: 0; z-index: 50;
+    max-width: none !important; margin: 0 !important;
+    box-shadow: 0 14px 34px rgba(15,23,42,.25);
+}
+
+/* Subject: the article is the headline */
+.clusterlane-inspector-head__subject { margin-top: 22px; max-width: 62%; position: relative; z-index: 1; }
+#clusterlane-ai-head .clusterlane-inspector-head__title {
+    margin: 0 !important; padding: 0 !important;
+    font-size: clamp(1.4rem, 1.1rem + 1.1vw, 2rem) !important;
+    font-weight: 800 !important; line-height: 1.15 !important; letter-spacing: -.015em !important;
+    color: #fff !important; text-wrap: balance;
+}
+.clusterlane-inspector-head__meta { margin-top: 10px; font-size: 12.5px; color: var(--ih-text); line-height: 1.8; }
+.clusterlane-inspector-head__meta span[style*="color:#64748b"] { color: var(--ih-text) !important; }
+.clusterlane-inspector-head__meta strong { color: var(--ih-text-hi); font-weight: 600; }
+.clusterlane-inspector-head .clusterlane-ai-tax-pill--cat { background: rgba(59,130,246,.22); color: #bfdbfe; border-color: rgba(96,165,250,.35); }
+.clusterlane-inspector-head .clusterlane-ai-tax-pill--tag { background: rgba(255,255,255,.08); color: #cbd5e1; border-color: rgba(255,255,255,.14); }
+.clusterlane-inspector-head__actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
+.clusterlane-inspector-head__btn {
+    display: inline-flex; align-items: center; height: 34px; padding: 0 14px;
+    border-radius: 9px; border: 1px solid rgba(255,255,255,.16);
+    color: var(--ih-text-hi); font-size: 13px; font-weight: 600; text-decoration: none;
+    transition: background 120ms, border-color 120ms;
+}
+.clusterlane-inspector-head__btn:hover, .clusterlane-inspector-head__btn:focus { background: rgba(255,255,255,.08); color: #fff; border-color: rgba(255,255,255,.26); box-shadow: none; }
+.clusterlane-inspector-head__btn:focus-visible { outline: 2px solid var(--ih-node); outline-offset: 2px; }
+.clusterlane-inspector-head__btn--primary { background: #fff; border-color: #fff; color: var(--ih-ink); }
+.clusterlane-inspector-head__btn--primary:hover, .clusterlane-inspector-head__btn--primary:focus { background: #e2e8f0; border-color: #e2e8f0; color: var(--ih-ink); }
+
+/* Empty state (before an article is chosen) */
+.clusterlane-inspector-head__empty { margin-top: 16px; }
+.clusterlane-inspector-head__empty > p { margin: 0; max-width: 120ch; color: var(--ih-text); font-size: 13.5px; line-height: 1.6; }
+.clusterlane-inspector-head .clusterlane-ai-recent-label { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--ih-muted); }
+.clusterlane-inspector-head .clusterlane-ai-recent-item {
+    background: rgba(255,255,255,.05) !important; border-color: rgba(255,255,255,.10) !important; color: var(--ih-text-hi) !important;
+}
+.clusterlane-inspector-head .clusterlane-ai-recent-item:hover { background: rgba(255,255,255,.10) !important; border-color: rgba(96,165,250,.45) !important; }
+
+/* Which part shows: subject once #clusterlane-ai-title has text, otherwise the empty state */
+.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__subject { display: none; }
+.clusterlane-inspector-head:not(:has(#clusterlane-ai-title:empty)) .clusterlane-inspector-head__empty { display: none; }
+
+/* Featured image: fades in from the right, behind the text */
+.clusterlane-inspector-head--has-image::before {
+    content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 48%; z-index: -1;
+    border-radius: 0 14px 14px 0;
+    background-image: var(--clusterlane-header-img); background-size: cover; background-position: center;
+    -webkit-mask-image: linear-gradient(to right, transparent 0%, rgba(0,0,0,.55) 45%, #000 100%);
+            mask-image: linear-gradient(to right, transparent 0%, rgba(0,0,0,.55) 45%, #000 100%);
+    opacity: .85;
+}
+
+/* Cluster Inspector hero — same family */
+.clusterlane-ci-hero {
+    background: linear-gradient(135deg, #16223a 0%, #22314f 100%) !important;
+    border-radius: 14px !important;
+    padding: 22px 26px !important;
+    box-shadow: 0 10px 28px rgba(15,23,42,.18);
+}
+.clusterlane-ci-hero-title .clusterlane-page-hero-sub { color: #9fb0c8; }
+.clusterlane-ci-stat-label { color: #8ea0ba; }
+@media (max-width: 782px) {
+    .clusterlane-inspector-head { padding: 16px 16px 18px; }
+    .clusterlane-inspector-head__search { width: 100%; margin-left: 0; }
+    .clusterlane-inspector-head__subject { max-width: none; margin-top: 18px; }
+    .clusterlane-inspector-head--has-image::before { width: 100%; opacity: .28; -webkit-mask-image: none; mask-image: none; border-radius: 14px; }
+    .clusterlane-ci-hero { padding: 18px !important; }
+}
+
+/* =========================================================
+   Page heroes + Cluster Inspector detail head (0.98.065)
+   ========================================================= */
+
+/* Every page hero: same navy, radius and SVG tile as the dock */
+.clusterlane-page-hero {
+    background: linear-gradient(135deg, #16223a 0%, #22314f 100%) !important;
+    border-radius: 14px !important;
+    padding: 20px 24px !important;
+    box-shadow: 0 10px 28px rgba(15,23,42,.16);
+}
+.clusterlane-page-hero .clusterlane-page-hero-sub { color: #9fb0c8; }
+
+/* Detail head: the selected cluster is the headline */
+.clusterlane-ci-detail-header {
+    background: linear-gradient(135deg, #16223a 0%, #22314f 100%) !important;
+    padding: 20px 24px 16px !important;
+}
+.clusterlane-ci-detail-title-row { align-items: center !important; gap: 18px; margin-bottom: 14px !important; }
+.clusterlane-ci-detail-titles { min-width: 0; flex: 1 1 auto; }
+.clusterlane-ci-detail-kind { display: block; font-size: 12.5px; color: #9fb0c8; margin-bottom: 4px; }
+#clusterlane-ci-detail-panel .clusterlane-ci-detail-label {
+    margin: 0 !important; color: #fff !important; text-transform: none !important; letter-spacing: -.015em !important;
+    font-size: clamp(1.3rem, 1.05rem + .9vw, 1.8rem) !important; font-weight: 800 !important; line-height: 1.15 !important;
+}
+.clusterlane-ci-detail-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
+.clusterlane-ci-detail-ai-btn { height: 32px !important; font-size: 12.5px !important; cursor: pointer; background: transparent; font-family: inherit; }
+
+/* Health = the lit node (same device as the active tab in the dock) */
+.clusterlane-ci-detail-health {
+    display: flex; align-items: center; gap: 10px; flex-shrink: 0;
+    padding: 6px 14px 6px 12px; border-radius: 999px;
+    background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
+}
+.clusterlane-ci-detail-health[hidden] { display: none; }
+.clusterlane-ci-health-node { width: 10px; height: 10px; border-radius: 50%; background: var(--clusterlane-hn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--clusterlane-hn) 25%, transparent), 0 0 14px 2px color-mix(in srgb, var(--clusterlane-hn) 60%, transparent); }
+.clusterlane-ci-health-value { font-size: 1.35rem; font-weight: 800; color: #fff; line-height: 1; }
+.clusterlane-ci-health-label { font-size: 12px; color: #9fb0c8; }
+.clusterlane-ci-detail-health--good { --clusterlane-hn: #22c55e; }
+.clusterlane-ci-detail-health--fair { --clusterlane-hn: #f59e0b; }
+.clusterlane-ci-detail-health--weak { --clusterlane-hn: #ef4444; }
+.clusterlane-ci-detail-health .clusterlane-tip-icon { background: rgba(255,255,255,.18); color: #e2e8f0; }
+
+/* Info bar under the headline: kind/articles/health are in the head now */
+.clusterlane-ci-detail-header .clusterlane-gib-head { display: none !important; }
+.clusterlane-ci-detail-header .clusterlane-graph-infobar { color: #9fb0c8 !important; font-size: 12px; }
+.clusterlane-ci-detail-header .clusterlane-graph-infobar strong { color: #f1f5f9; }
+.clusterlane-ci-detail-header .clusterlane-graph-infobar > span:first-of-type:not(.clusterlane-gib-head) { padding-left: 0; }
+@media (max-width: 782px) {
+    .clusterlane-ci-detail-title-row { flex-wrap: wrap; }
+    .clusterlane-ci-detail-titles { flex-basis: 100%; }
+    .clusterlane-ci-detail-health { order: 2; }
+    .clusterlane-ci-detail-actions { order: 3; margin-left: auto; }
+}
+
+/* AI Analysis button in the detail head (outside .clusterlane-inspector-head scope) */
+#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn { color: #f1f5f9; border: 1px solid rgba(255,255,255,.16); border-radius: 9px; padding: 0 14px; }
+#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:hover { background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.26); }
+#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
+#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:disabled { opacity: .6; cursor: progress; }
+
+/* =========================================================
+   Glyphs (0.98.066) — line icons that replace emoji.
+   Sized in em, so they follow the font size of whatever they sit in.
+   ========================================================= */
+.clusterlane-g {
+    display: inline-block;
+    width: 1.05em;
+    height: 1.05em;
+    vertical-align: -0.16em;
+    margin-right: .38em;
+    flex-shrink: 0;
+    overflow: visible;
+}
+
+/* Icon-only spots: no trailing gap */
+.clusterlane-type-icon .clusterlane-g, .clusterlane-ai-recent-icon .clusterlane-g, .clusterlane-ob-icon .clusterlane-g, .clusterlane-tms-empty-icon .clusterlane-g, .clusterlane-site-analysis-icon .clusterlane-g, .clusterlane-support-icon .clusterlane-g, .clusterlane-improve-panel-icon .clusterlane-g, .clusterlane-action-btn__icon .clusterlane-g, .clusterlane-ci-cluster-type .clusterlane-g, .clusterlane-ai-cluster-type .clusterlane-g, .clusterlane-type-filter .clusterlane-g, #clusterlane-sa-print .clusterlane-g { margin-right: 0; }
+
+/* Big feature icons keep their visual weight */
+.clusterlane-ob-icon .clusterlane-g, .clusterlane-tms-empty-icon .clusterlane-g { stroke-width: 1.6; color: #16a34a; }
+.clusterlane-support-icon .clusterlane-g, .clusterlane-site-analysis-icon .clusterlane-g { color: #fff; }
+
+/* Card title icons (Cluster Graph, Site Intelligence Analysis): the same blue tile as page marks */
+.clusterlane-site-analysis-icon {
+    display: inline-flex; align-items: center; justify-content: center;
+    width: 34px; height: 34px; border-radius: 10px; font-size: 17px !important; color: #fff;
+    background: linear-gradient(135deg, #22314f, #16223a);
+    box-shadow: 0 0 0 1px rgba(255,255,255,.08) inset;
+}
+
+/* Article Inspector head (0.98.067)
+   (1) Before an article is chosen, the page name is the headline — same size as other page heroes.
+   (3) Recently inspected: light text on the dark head. */
+.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__page {
+    font-size: 1.6rem; font-weight: 800; color: #fff; letter-spacing: -.01em; line-height: 1.15;
+}
+.clusterlane-inspector-head .clusterlane-ai-recent-title { color: #e2e8f0; }
+.clusterlane-inspector-head .clusterlane-ai-recent-icon { color: #7c8ea8; opacity: 1; }
+.clusterlane-inspector-head .clusterlane-ai-recent-item:hover .clusterlane-ai-recent-title { color: #fff; }
+.clusterlane-inspector-head .clusterlane-ai-recent-item:hover .clusterlane-ai-recent-icon { color: #60a5fa; }
+.clusterlane-inspector-head .clusterlane-ai-recent-item:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
+
+/* =========================================================
+   Article Inspector — Articles to review (0.98.068)
+   Paged on the server; needs-attention first. Role colours
+   are the graph's: Pillar orange · Support yellow · Light green · Orphan blue.
+   ========================================================= */
+.clusterlane-wrap:has(#clusterlane-ai-title:not(:empty)) #clusterlane-ai-browse { display: none; }
+.clusterlane-browse { margin-top: 20px; }
+.clusterlane-browse__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
+.clusterlane-browse__head .clusterlane-ai-section-title { margin: 0; }
+/* Summary pill (0.98.123): count · sort order · orphans shortcut */
+.clusterlane-browse__summary { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 0; padding: 4px 6px; border-radius: 999px; background: #f1f5f9; font-size: 12.5px; color: #64748b; line-height: 1.4; }
+.clusterlane-browse__summary:empty { display: none; }
+.clusterlane-browse__sum-part { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; }
+.clusterlane-browse__sum-part + .clusterlane-browse__sum-part { border-left: 1px solid #e2e8f0; }
+.clusterlane-browse__sum-part b { color: #16223a; font-weight: 700; }
+.clusterlane-browse__sum-part--sort span { color: #94a3b8; }
+.clusterlane-browse__sum-orphans { margin: 0; border: 0; border-radius: 999px; background: #e4f0f8; color: #245f84; font: inherit; cursor: pointer; transition: background 120ms; }
+.clusterlane-browse__sum-part + .clusterlane-browse__sum-orphans { border-left: 0; margin-left: 4px; }
+.clusterlane-browse__sum-orphans b { color: #245f84; }
+.clusterlane-browse__sum-orphans:hover { background: #d3e6f3; }
+.clusterlane-browse__sum-orphans:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
+.clusterlane-browse__toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 14px; }
+.clusterlane-browse__search { position: relative; flex: 1 1 240px; max-width: 360px; }
+.clusterlane-browse__search .clusterlane-g { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: #94a3b8; margin: 0; }
+#clusterlane-ai-browse .clusterlane-browse__search input, #clusterlane-ai-browse select#clusterlane-ab-cluster, #clusterlane-ai-browse select#clusterlane-ab-meta, #clusterlane-ai-browse select#clusterlane-ab-lang, #clusterlane-seot .clusterlane-browse__search input, #clusterlane-seot select#clusterlane-seot-cluster, #clusterlane-seot select#clusterlane-seot-lang {
+    height: 36px; min-height: 0; margin: 0; border: 1.5px solid #e2e8f0; border-radius: 9px;
+    font-size: 13px; color: #0f172a; background-color: #fff; box-shadow: none;
+}
+#clusterlane-ai-browse .clusterlane-browse__search input, #clusterlane-seot .clusterlane-browse__search input { width: 100%; padding: 0 12px 0 34px; }
+#clusterlane-ai-browse select#clusterlane-ab-cluster, #clusterlane-ai-browse select#clusterlane-ab-meta, #clusterlane-ai-browse select#clusterlane-ab-lang, #clusterlane-seot select#clusterlane-seot-cluster, #clusterlane-seot select#clusterlane-seot-lang { padding: 0 32px 0 12px; max-width: 260px; }
+#clusterlane-ai-browse select#clusterlane-ab-meta.is-active { border-color: #2563eb; background-color: #eff6ff; color: #1e3a8a; font-weight: 600; }
+#clusterlane-ai-browse .clusterlane-browse__search input:focus, #clusterlane-ai-browse select#clusterlane-ab-cluster:focus, #clusterlane-ai-browse select#clusterlane-ab-meta:focus, #clusterlane-ai-browse select#clusterlane-ab-lang:focus, #clusterlane-seot .clusterlane-browse__search input:focus, #clusterlane-seot select#clusterlane-seot-cluster:focus, #clusterlane-seot select#clusterlane-seot-lang:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.15); }
+.clusterlane-browse__roles { display: inline-flex; flex-wrap: wrap; gap: 4px; padding: 3px; border-radius: 10px; background: #f1f5f9; }
+.clusterlane-browse__roles button {
+    display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px;
+    border: 0; border-radius: 7px; background: transparent; color: #475569;
+    font-size: 12.5px; font-weight: 500; cursor: pointer;
+}
+.clusterlane-browse__roles button em { font-style: normal; color: #94a3b8; font-weight: 600; }
+.clusterlane-browse__roles button:hover { color: #0f172a; }
+.clusterlane-browse__roles button.is-active { background: #fff; color: #0f172a; font-weight: 600; box-shadow: 0 1px 2px rgba(15,23,42,.08); }
+.clusterlane-browse__roles button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
+.clusterlane-role-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.clusterlane-role-dot--pillar { background: #e76c2e; }
+.clusterlane-role-dot--support { background: #f5c842; }
+.clusterlane-role-dot--light { background: #90b865; }
+.clusterlane-role-dot--orphan { background: #5ba4cf; }
+.clusterlane-browse__table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; transition: opacity 150ms; }
+.clusterlane-browse.is-loading .clusterlane-browse__table-wrap { opacity: .55; }
+.clusterlane-browse__table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.clusterlane-browse__table > thead > tr > th {
+    position: sticky; top: 0; z-index: 1;
+    padding: 9px 12px; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
+    text-align: left; font-size: 12px; font-weight: 600; color: #64748b; white-space: nowrap;
+}
+.clusterlane-browse__table > thead > tr > th.num, .clusterlane-browse__table > tbody > tr > td.num { text-align: right; font-variant-numeric: tabular-nums; }
+.clusterlane-browse__table > thead > tr > th button { all: unset; cursor: pointer; }
+.clusterlane-browse__table > thead > tr > th button:hover { color: #0f172a; }
+.clusterlane-browse__table > thead > tr > th button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; border-radius: 3px; }
+.clusterlane-browse__table > thead > tr > th button.is-sorted { color: #0f172a; }
+.clusterlane-browse__table > thead > tr > th button.is-sorted::after { content: " ↓"; }
+.clusterlane-browse__table > thead > tr > th button.is-sorted[data-dir="asc"]::after { content: " ↑"; }
+.clusterlane-browse__table > tbody > tr > td { padding: 10px 12px; border-bottom: 1px solid #f1f5f9; color: #334155; vertical-align: middle; }
+.clusterlane-browse__table > tbody > tr { cursor: pointer; transition: background 100ms; }
+.clusterlane-browse__table > tbody > tr:hover { background: #f8fafc; }
+.clusterlane-browse__table > tbody > tr:last-child > td { border-bottom: 0; }
+.clusterlane-browse__col-title { min-width: 240px; }
+.clusterlane-browse__open { all: unset; display: block; font-weight: 600; color: #0f172a; cursor: pointer; }
+.clusterlane-browse__open:hover, tr:hover .clusterlane-browse__open { color: #1d4ed8; }
+.clusterlane-browse__open:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; border-radius: 3px; }
+.clusterlane-browse__cluster { display: block; margin-top: 2px; font-size: 12px; color: #94a3b8; }
+.clusterlane-browse__more { color: #64748b; font-weight: 600; }
+.clusterlane-role-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: #334155; white-space: nowrap; }
+.clusterlane-browse__score { display: inline-block; min-width: 30px; font-weight: 700; }
+.clusterlane-browse__score--good { color: #15803d; }
+.clusterlane-browse__score--fair { color: #b45309; }
+.clusterlane-browse__score--weak { color: #dc2626; }
+.clusterlane-browse__table > tbody > tr > td.clusterlane-browse__zero { color: #dc2626; font-weight: 700; }
+.clusterlane-browse__muted { color: #94a3b8; font-size: 12.5px; }
+.clusterlane-browse__empty { text-align: center; padding: 28px 12px !important; color: #64748b; cursor: default; }
+.clusterlane-browse__pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; min-height: 20px; }
+.clusterlane-browse__pages { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; color: #475569; }
+.clusterlane-browse__pages button {
+    height: 30px; padding: 0 12px; border: 1px solid #e2e8f0; border-radius: 8px;
+    background: #fff; color: #0f172a; font-size: 12.5px; font-weight: 600; cursor: pointer;
+}
+.clusterlane-browse__pages button:hover:not(:disabled) { border-color: #93c5fd; background: #eff6ff; }
+.clusterlane-browse__pages button:disabled { opacity: .45; cursor: default; }
+.clusterlane-inspector-head__btn--quiet { border-color: transparent; color: #9fb0c8; }
+.clusterlane-inspector-head__btn--quiet:hover { color: #fff; }
+@media (max-width: 782px) {
+    .clusterlane-browse__opt { display: none; }
+    .clusterlane-browse__search { max-width: none; flex-basis: 100%; }
+    #clusterlane-ai-browse select#clusterlane-ab-cluster, #clusterlane-ai-browse select#clusterlane-ab-meta, #clusterlane-ai-browse select#clusterlane-ab-lang, #clusterlane-seot select#clusterlane-seot-cluster, #clusterlane-seot select#clusterlane-seot-lang { flex: 1 1 auto; max-width: none; }
+    .clusterlane-browse__col-title { min-width: 0; }
+}
+
+/* Article Inspector list — fixed column widths (0.98.123), so columns don't
+   jump when a filter changes what the rows contain. The title takes the rest
+   and wraps to at most two lines; on mobile the optional columns are hidden
+   and the fixed columns shrink. */
+.clusterlane-ai-table { table-layout: fixed; }
+.clusterlane-ai-table > thead > tr > th:nth-child(2) { width: 132px; }
+.clusterlane-ai-table > thead > tr > th:nth-child(3) { width: 84px; }
+.clusterlane-ai-table > thead > tr > th:nth-child(4) { width: 64px; }
+.clusterlane-ai-table > thead > tr > th:nth-child(5) { width: 72px; }
+.clusterlane-ai-table > thead > tr > th:nth-child(6) { width: 84px; }
+.clusterlane-ai-table > thead > tr > th:nth-child(7) { width: 112px; }
+.clusterlane-ai-table .clusterlane-browse__col-title { min-width: 0; }
+.clusterlane-ai-table .clusterlane-browse__open { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
+.clusterlane-ai-table .clusterlane-browse__cluster { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clusterlane-ai-table .clusterlane-role-pill { max-width: 100%; }
+@media (max-width: 782px) {
+    /* Role shows only its coloured dot; the header keeps the word for screen readers. */
+    .clusterlane-ai-table > thead > tr > th:nth-child(2) { width: 14px; font-size: 0; }
+    .clusterlane-ai-table > thead > tr > th:nth-child(2) .clusterlane-tip { display: none; }
+    .clusterlane-ai-table .clusterlane-role-pill { font-size: 0; gap: 0; }
+    .clusterlane-ai-table > thead > tr > th:nth-child(3) { width: 48px; }
+    .clusterlane-ai-table > thead > tr > th:nth-child(4) { width: 34px; }
+    .clusterlane-ai-table > thead > tr > th, .clusterlane-ai-table > tbody > tr > td { padding-left: 7px; padding-right: 7px; }
+    .clusterlane-ai-table .clusterlane-thumb--md { width: 30px; height: 30px; margin-right: 8px; }
+}
+
+/* Article Inspector head — compact empty state (0.98.069)
+   The table's filter bar replaces the head search until an article is open;
+   the head search comes back with the article (quick switching). */
+.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) { padding: 18px 24px 18px; }
+.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__search { display: none; }
+.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__empty { margin-top: 10px; }
+.clusterlane-inspector-head__empty > p { margin-left: 46px !important; }
+
+/* align with the title, past the mark */
+.clusterlane-inspector-head #clusterlane-ai-recent-slot:not(:empty) { margin: 12px 0 0 46px; }
+.clusterlane-inspector-head #clusterlane-ai-recent-slot > div { margin-top: 0 !important; }
+
+/* Recently inspected: one row of chips instead of a stacked list */
+.clusterlane-inspector-head .clusterlane-ai-recent-label { display: inline-block; margin: 0 8px 6px 0; }
+.clusterlane-inspector-head .clusterlane-ai-recent-list { flex-direction: row; flex-wrap: wrap; gap: 6px; }
+.clusterlane-inspector-head .clusterlane-ai-recent-item {
+    width: auto; max-width: 260px; padding: 5px 12px 5px 10px; border-radius: 999px; gap: 6px;
+}
+.clusterlane-inspector-head .clusterlane-ai-recent-title { font-size: 12.5px; }
+@media (max-width: 782px) {
+    .clusterlane-inspector-head__empty > p, .clusterlane-inspector-head #clusterlane-ai-recent-slot:not(:empty) { margin-left: 0 !important; }
+}
+
+/* =========================================================
+   Cluster Inspector — cluster table (0.98.072)
+   Same component as "Articles to review"; sorted & filtered client-side.
+   ========================================================= */
+.clusterlane-ci-table tr.clusterlane-ci-cluster-row { display: table-row; padding: 0; border: 0; border-radius: 0; margin: 0; background: transparent; }
+.clusterlane-ci-table tr.clusterlane-ci-cluster-row:hover { background: #f8fafc; }
+.clusterlane-ci-table tr.clusterlane-ci-cluster-row--active, .clusterlane-ci-table tr.clusterlane-ci-cluster-row--active:hover { background: #eff6ff; box-shadow: inset 3px 0 0 #3b82f6; }
+.clusterlane-ci-table .clusterlane-ci-cluster-type { display: inline-flex; color: #94a3b8; margin-right: 8px; vertical-align: -2px; }
+.clusterlane-ci-table .clusterlane-browse__open { display: inline-block; max-width: calc(100% - 28px); vertical-align: top; text-align: left; }
+.clusterlane-ci-table > tbody > tr > td.clusterlane-browse__zero { color: #dc2626; }
+.clusterlane-band-dot--weak { background: #ef4444; }
+.clusterlane-band-dot--fair { background: #f59e0b; }
+.clusterlane-band-dot--good { background: #22c55e; }
+
+/* Health cell: the lit node + value */
+.clusterlane-ci-health-cell { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: #0f172a; }
+.clusterlane-ci-health-cell .clusterlane-ci-health-node { width: 8px; height: 8px; }
+.clusterlane-ci-health-cell.clusterlane-ci-detail-health--good { color: #15803d; }
+.clusterlane-ci-health-cell.clusterlane-ci-detail-health--fair { color: #b45309; }
+.clusterlane-ci-health-cell.clusterlane-ci-detail-health--weak { color: #dc2626; }
+.clusterlane-browse__table > tbody > tr > td.clusterlane-browse__muted { color: #94a3b8; }
+
+/* Inline cluster detail (0.98.073): the open cluster's panel sits in a row
+   right under its table row, joined by the blue edge of the active row. */
+.clusterlane-ci-table > tbody > tr.clusterlane-ci-detail-row, .clusterlane-ci-table > tbody > tr.clusterlane-ci-detail-row:hover { cursor: default; background: #f1f5f9; }
+.clusterlane-ci-table > tbody > tr.clusterlane-ci-detail-row > td { padding: 0 12px 14px 12px; border-bottom: 1px solid #e2e8f0; box-shadow: inset 3px 0 0 #3b82f6; }
+.clusterlane-ci-table > tbody > tr.clusterlane-ci-cluster-row--active > td { border-bottom-color: transparent; }
+.clusterlane-ci-detail-row #clusterlane-ci-detail-panel { margin-top: 0 !important; }
+.clusterlane-ci-detail-row #clusterlane-ci-detail-panel > .clusterlane-panel-card { margin: 0; box-shadow: 0 6px 18px rgba(15,23,42,.10); cursor: auto; }
+.clusterlane-improve-block-icon .clusterlane-g { margin-right: 0; }
+
+/* Fixed column widths (0.98.123) for the Cluster Inspector list, its members
+   table and the Cluster Admin table — columns no longer jump when a filter or
+   sort changes the rows. Titles take the remaining width. The phone rules
+   below (and further down) override these widths. */
+.clusterlane-ci-table { table-layout: fixed; }
+.clusterlane-ci-table > thead > tr > th:nth-child(2) { width: 172px; }
+.clusterlane-ci-table > thead > tr > th:nth-child(3) { width: 84px; }
+.clusterlane-ci-table > thead > tr > th:nth-child(4) { width: 92px; }
+.clusterlane-ci-table > thead > tr > th:nth-child(5) { width: 84px; }
+.clusterlane-ci-table > thead > tr > th:nth-child(6) { width: 92px; }
+.clusterlane-ci-table > tbody > tr.clusterlane-ci-cluster-row > td.clusterlane-browse__col-title { overflow-wrap: anywhere; }
+.clusterlane-ci-members { table-layout: fixed; }
+.clusterlane-ci-members > thead > tr > th:nth-child(2) { width: 140px; }
+.clusterlane-ci-members > thead > tr > th:nth-child(3) { width: 48px; }
+.clusterlane-ci-members > thead > tr > th:nth-child(4) { width: 52px; }
+.clusterlane-ci-members > thead > tr > th:nth-child(5) { width: 60px; }
+.clusterlane-ci-members > thead > tr > th:nth-child(6) { width: 64px; }
+.clusterlane-ci-members > thead > tr > th:nth-child(7) { width: 116px; }
+.clusterlane-ci-members .clusterlane-ci-member-link { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; }
+.clusterlane-ci-members .clusterlane-browse__titlecell > .clusterlane-thumb { flex-shrink: 0; }
+.clusterlane-ca-table { table-layout: fixed; }
+.clusterlane-ca-table > thead > tr > th:nth-child(2) { width: 84px; }
+.clusterlane-ca-table > thead > tr > th:nth-child(3) { width: 92px; }
+.clusterlane-ca-table > thead > tr > th:nth-child(4) { width: 92px; }
+.clusterlane-ca-table > thead > tr > th:nth-child(5) { width: 168px; }
+.clusterlane-ca-table .clusterlane-ca-name { overflow-wrap: anywhere; }
+@media (max-width: 782px) {
+    /* Phones: only 3–4 columns remain, so these two tables go back to
+       content-based widths (hidden columns in a fixed-layout table leave
+       their width behind in Chrome). */
+    .clusterlane-ci-members, .clusterlane-ca-table { table-layout: auto; }
+    /* Members: Out, Words and the action buttons are hidden (a tap on the row
+       opens the article in Article Inspector; Make pillar and Edit are on
+       wider screens). The role shows as its dot, a chosen pillar as a star. */
+    .clusterlane-ci-members-wrap .clusterlane-ci-members > thead > tr > th, .clusterlane-ci-members-wrap .clusterlane-ci-members > tbody > tr > td { padding-left: 6px; padding-right: 6px; }
+    .clusterlane-ci-members > thead > tr > th:is(:nth-child(4), :nth-child(6), :nth-child(7)),
+    .clusterlane-ci-members > tbody > tr > td:is(:nth-child(4), :nth-child(6), :nth-child(7)) { display: none; }
+    .clusterlane-ci-members > thead > tr > th:nth-child(2) { font-size: 0; }
+    .clusterlane-ci-members > thead > tr > th .clusterlane-tip { display: none; }
+    .clusterlane-ci-members > thead > tr > th:not(:first-child) { width: 1%; white-space: nowrap; }
+    .clusterlane-ci-members .clusterlane-role-pill { font-size: 0; gap: 0; }
+    .clusterlane-ci-members > tbody > tr > td:nth-child(2) { white-space: nowrap; }
+    .clusterlane-ci-members .clusterlane-ci-chosen { margin: 0 0 0 2px; padding: 0; background: none; font-size: 0; }
+    .clusterlane-ci-members .clusterlane-ci-chosen::before { content: "★"; font-size: 11px; color: #e76c2e; }
+    .clusterlane-ci-members .clusterlane-thumb--sm { display: none; }
+    /* Cluster Admin: the article count is in the cluster's editor; actions stack. */
+    .clusterlane-ca-table > thead > tr > th, .clusterlane-ca-table > tbody > tr > td { padding-left: 7px; padding-right: 7px; }
+    .clusterlane-ca-table > thead > tr > th:nth-child(2), .clusterlane-ca-table > tbody > tr.clusterlane-ca-row > td:nth-child(2) { display: none; }
+    .clusterlane-ca-table > thead > tr > th .clusterlane-tip { display: none; }
+    .clusterlane-ca-table > thead > tr > th:not(:first-child) { width: 1%; white-space: nowrap; }
+    .clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn { display: flex; justify-content: center; box-sizing: border-box; margin: 0 0 4px; }
+}
+
+/* Inline cluster detail on phones (0.98.074): the panel must not widen the
+   table. A fixed table layout keeps columns to the screen width, and the
+   panel's own content wraps inside its row. */
+@media (max-width: 782px) {
+    .clusterlane-ci-table { table-layout: fixed; width: 100%; }
+    .clusterlane-ci-table > thead > tr > th:is(:nth-child(2), :nth-child(3), :nth-child(5)), .clusterlane-ci-table > tbody > tr.clusterlane-ci-cluster-row > td:is(:nth-child(2), :nth-child(3), :nth-child(5)) {
+        display: table-cell !important; width: 0; padding: 0 !important; border: 0;
+        overflow: hidden; font-size: 0; visibility: hidden;
+    }
+    .clusterlane-ci-table > thead > tr > th, .clusterlane-ci-table > tbody > tr > td { padding-left: 8px; padding-right: 8px; }
+    .clusterlane-ci-table > thead > tr > th:nth-child(4) { width: 66px; }
+    .clusterlane-ci-table > thead > tr > th:nth-child(6) { width: 72px; }
+    .clusterlane-ci-table > tbody > tr.clusterlane-ci-cluster-row > td.clusterlane-browse__col-title { overflow-wrap: anywhere; } /* wraps; the name is a button and can't be truncated with an ellipsis */
+    .clusterlane-ci-table > tbody > tr.clusterlane-ci-detail-row > td { padding: 0 4px 10px; }
+    .clusterlane-ci-detail-row #clusterlane-ci-detail-panel, .clusterlane-ci-detail-row #clusterlane-ci-detail-panel > .clusterlane-panel-card { max-width: 100%; min-width: 0; }
+    .clusterlane-ci-detail-row .clusterlane-ci-detail-header { padding: 16px 14px 12px !important; }
+    .clusterlane-ci-detail-row .clusterlane-graph-infobar { row-gap: 4px; }
+    .clusterlane-ci-detail-row .clusterlane-graph-infobar > span { white-space: normal; }
+    .clusterlane-ci-detail-row #clusterlane-ci-detail-panel > .clusterlane-panel-card > div[style*="padding:20px 24px"] { padding: 14px 12px !important; }
+    .clusterlane-ci-detail-row #clusterlane-ci-detail-graph { height: 300px !important; }
+    .clusterlane-ci-detail-row table { display: block; max-width: 100%; overflow-x: auto; }
+    .clusterlane-ci-detail-row .clusterlane-improve-block-head { gap: 8px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-block-title { min-width: 0; overflow-wrap: anywhere; }
+    .clusterlane-ci-detail-row [class*="clusterlane-tms-"], .clusterlane-ci-detail-row [class*="clusterlane-gap-"] { overflow-wrap: break-word; }
+}
+
+/* =========================================================
+   Text Match Scanner v2 (0.98.075)
+   ========================================================= */
+.clusterlane-tm-question { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: 14px; color: #0f172a; font-weight: 600; }
+.clusterlane-tm-question label { font-weight: 600; }
+.clusterlane-tm-question .clusterlane-tms-select { height: 36px; min-height: 0; border: 1.5px solid #e2e8f0; border-radius: 9px; padding: 0 30px 0 10px; font-size: 13px; font-weight: 500; }
+.clusterlane-tm-hint { margin: 10px 0 16px; font-size: 13px; color: #64748b; }
+.clusterlane-tm-actions-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
+.clusterlane-tm-status { font-size: 13px; color: #64748b; }
+.clusterlane-tm-linkbtn { margin-left: auto; background: none; border: 0; padding: 0; color: #64748b; font-size: 12.5px; text-decoration: underline; cursor: pointer; }
+.clusterlane-tm-linkbtn:hover { color: #0f172a; }
+.clusterlane-tm-progress-label { font-size: 12px; color: #64748b; margin-top: 4px; }
+
+/* Before the first search */
+.clusterlane-tm-preview { opacity: .8; }
+.clusterlane-tm-preview-label { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: #94a3b8; }
+.clusterlane-tm-preview .clusterlane-tm-group { border-style: dashed; }
+.clusterlane-tm-preview .clusterlane-tm-btn { pointer-events: none; }
+.clusterlane-tm-summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 12px; font-size: 13.5px; color: #334155; }
+.clusterlane-tm-orphan-count { color: #1d4ed8; font-weight: 600; }
+.clusterlane-tm-restore { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; margin-bottom: 14px; border-radius: 10px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a8a; font-size: 13px; }
+.clusterlane-tm-group { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; margin-bottom: 14px; overflow: hidden; }
+.clusterlane-tm-group-head { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 13px; color: #64748b; }
+.clusterlane-tm-group-head a { color: #0f172a; text-decoration: none; }
+.clusterlane-tm-group-head a:hover strong { text-decoration: underline; }
+.clusterlane-tm-group-count { margin-left: auto; font-size: 12px; color: #94a3b8; }
+.clusterlane-tm-row { display: flex; align-items: flex-start; gap: 16px; padding: 12px 16px; border-bottom: 1px solid #f1f5f9; transition: background 150ms; }
+.clusterlane-tm-row:last-child { border-bottom: 0; }
+.clusterlane-tm-row--orphan { box-shadow: inset 3px 0 0 #5ba4cf; }
+.clusterlane-tm-row-main { flex: 1; min-width: 0; }
+.clusterlane-tm-sentence { margin: 0 0 4px; font-size: 13.5px; line-height: 1.55; color: #475569; }
+.clusterlane-tm-hit { color: #1d4ed8; font-weight: 600; text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; background: #eff6ff; border-radius: 3px; padding: 0 2px; }
+.clusterlane-tm-target { margin: 0; font-size: 12.5px; color: #64748b; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
+.clusterlane-tm-target strong { color: #0f172a; }
+.clusterlane-tm-target .clusterlane-g { margin-right: 0; color: #94a3b8; }
+.clusterlane-tm-note { font-size: 12px; color: #94a3b8; }
+.clusterlane-tm-row-actions { display: flex; gap: 6px; flex-shrink: 0; }
+.clusterlane-tm-btn { display: inline-flex; align-items: center; height: 30px; padding: 0 12px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #334155; font-size: 12.5px; font-weight: 600; cursor: pointer; white-space: nowrap; }
+.clusterlane-tm-btn:hover:not(:disabled) { border-color: #cbd5e1; background: #f8fafc; }
+.clusterlane-tm-btn:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-tm-btn:disabled { opacity: .6; cursor: progress; }
+.clusterlane-tm-btn--apply { background: #2563eb; border-color: #2563eb; color: #fff; }
+.clusterlane-tm-btn--apply:hover:not(:disabled) { background: #1d4ed8; border-color: #1d4ed8; }
+.clusterlane-tm-btn--apply:disabled, .clusterlane-tm-btn--apply[aria-disabled="true"] { background: #e2e8f0; border-color: #e2e8f0; color: #94a3b8; opacity: 1; cursor: not-allowed; }
+.clusterlane-tm-apply-all { height: 28px; }
+.clusterlane-tm-more { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; margin: 14px 0 0; }
+.clusterlane-tm-row--linked { background: #f0fdf4; }
+.clusterlane-tm-done { margin: 0; font-size: 13px; color: #166534; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
+.clusterlane-tm-done .clusterlane-g { color: #16a34a; }
+@media (max-width: 782px) {
+    .clusterlane-tm-row { flex-direction: column; gap: 10px; }
+    .clusterlane-tm-row-actions { align-self: flex-start; }
+    .clusterlane-tm-group-head { flex-wrap: wrap; }
+    .clusterlane-tm-linkbtn { margin-left: 0; }
+}
+
+/* =========================================================
+   AI Link Suggestions v2 (0.98.079)
+   ========================================================= */
+.clusterlane-ls2-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
+.clusterlane-ls2-modes button {
+    display: flex; align-items: flex-start; gap: 12px; text-align: left; padding: 14px 16px;
+    border: 1.5px solid #e2e8f0; border-radius: 12px; background: #fff; cursor: pointer; color: #334155;
+    transition: border-color 120ms, background 120ms, box-shadow 120ms;
+}
+.clusterlane-ls2-modes button .clusterlane-g { font-size: 20px; color: #94a3b8; margin: 2px 0 0; }
+.clusterlane-ls2-modes button strong { display: block; font-size: 14px; color: #0f172a; }
+.clusterlane-ls2-modes button em { display: block; font-style: normal; font-size: 12.5px; color: #64748b; margin-top: 2px; }
+.clusterlane-ls2-modes button:hover { border-color: #cbd5e1; }
+.clusterlane-ls2-modes button.is-active { border-color: #3b82f6; background: #eff6ff; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
+.clusterlane-ls2-modes button.is-active .clusterlane-g { color: #2563eb; }
+.clusterlane-ls2-modes button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-ls2-q { margin: 4px 0 12px; font-size: 13.5px; font-weight: 600; color: #0f172a; }
+.clusterlane-ls2-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
+.clusterlane-ls2 .clusterlane-browse__search input { width: 100%; height: 36px; min-height: 0; margin: 0; padding: 0 12px 0 34px; border: 1.5px solid #e2e8f0; border-radius: 9px; font-size: 13px; box-shadow: none; }
+.clusterlane-ls2 .clusterlane-browse__search input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.15); }
+.clusterlane-ls2-search { max-width: 420px; }
+.clusterlane-ls2-results { max-width: 420px; margin-top: 6px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; box-shadow: 0 10px 24px rgba(15,23,42,.10); overflow: hidden; }
+.clusterlane-ls2-results button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 7px 14px; border: 0; border-bottom: 1px solid #f1f5f9; background: #fff; font-size: 13px; color: #0f172a; cursor: pointer; }
+.clusterlane-ls2-res-title { flex: 1 1 auto; min-width: 0; }
+.clusterlane-ls2-res-note { flex: none; font-size: 11.5px; color: #94a3b8; }
+.clusterlane-ls2-results button.is-content .clusterlane-ls2-res-title { color: #475569; }
+.clusterlane-ls2-results button:hover { background: #f8fafc; }
+.clusterlane-ls2-results p { margin: 0; padding: 10px 14px; }
+.clusterlane-ls2-list { display: flex; flex-direction: column; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }
+.clusterlane-ls2-pick {
+    display: flex; align-items: center; gap: 14px; padding: 10px 14px; text-align: left;
+    border: 0; border-bottom: 1px solid #f1f5f9; background: #fff; cursor: pointer; color: #334155;
+}
+.clusterlane-ls2-pick:last-child { border-bottom: 0; }
+.clusterlane-ls2-pick:hover { background: #f8fafc; }
+.clusterlane-ls2-pick:focus-visible { outline: 2px solid #3b82f6; outline-offset: -2px; }
+.clusterlane-ls2-pick-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.clusterlane-ls2-pick-main strong { color: #0f172a; font-size: 13.5px; }
+.clusterlane-ls2-pick-meta { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
+.clusterlane-ls2-pick-go { flex-shrink: 0; font-size: 12.5px; font-weight: 600; color: #2563eb; display: inline-flex; align-items: center; gap: 4px; }
+.clusterlane-ls2-pick-go .clusterlane-g { margin: 0; }
+.clusterlane-ls2-chosen { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; margin-bottom: 14px; border-radius: 10px; background: #16223a; color: #cbd5e1; font-size: 13.5px; }
+.clusterlane-ls2-chosen strong { color: #fff; }
+.clusterlane-ls2-chosen .clusterlane-role-pill { color: #e2e8f0; }
+.clusterlane-ls2-chosen .clusterlane-tm-note { color: #9fb0c8; }
+.clusterlane-ls2-row { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; background: #fff; overflow: hidden; }
+.clusterlane-ls2-row-head { display: flex; align-items: center; gap: 12px; padding: 10px 14px; }
+.clusterlane-ls2-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
+.clusterlane-ls2-row-main strong { color: #0f172a; font-size: 13.5px; }
+.clusterlane-ls2-row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12px; color: #94a3b8; }
+.clusterlane-ls2-row-body:empty { display: none; }
+.clusterlane-ls2-row-body { border-top: 1px solid #f1f5f9; padding: 12px 14px; background: #fbfcfe; }
+.clusterlane-ls2-options { display: grid; gap: 8px; }
+.clusterlane-ls2-option { padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 9px; background: #fff; }
+.clusterlane-ls2-sentence { margin: 0 0 8px; font-size: 13.5px; line-height: 1.55; color: #334155; }
+.clusterlane-ls2-option-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.clusterlane-ls2-regen { margin: 10px 0 0; font-size: 12px; color: #94a3b8; }
+.clusterlane-ls2-row--linked { border-color: #bbf7d0; }
+.clusterlane-ls2-row--linked .clusterlane-ls2-row-body { background: #f0fdf4; }
+.clusterlane-ls2-linked { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; font-size: 13px; color: #166534; }
+.clusterlane-ls2-linked .clusterlane-g { color: #16a34a; margin: 0; }
+.clusterlane-ls2-linked-actions { margin-left: auto; display: flex; gap: 6px; }
+.clusterlane-ls2-loading { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #64748b; padding: 6px 0; }
+.clusterlane-ls2-empty { margin: 8px 0; padding: 14px; border: 1px dashed #cbd5e1; border-radius: 10px; font-size: 13px; color: #64748b; }
+.clusterlane-ls2-other { margin: 12px 0 0; }
+@media (max-width: 782px) {
+    .clusterlane-ls2-modes { grid-template-columns: 1fr; }
+    .clusterlane-ls2-pick { flex-wrap: wrap; }
+    .clusterlane-ls2-pick-go { width: 100%; }
+    .clusterlane-ls2-row-head { flex-wrap: wrap; }
+    .clusterlane-ls2-option-foot { flex-wrap: wrap; }
+    .clusterlane-ls2-linked-actions { margin-left: 0; }
+}
+
+/* Link Suggestions — why + AI ranking (0.98.081) */
+.clusterlane-ls2-why { font-size: 12px; color: #475569; }
+.clusterlane-ls2-why:empty, .clusterlane-ls2-ai:empty { display: none; }
+.clusterlane-ls2-ai { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: #1e3a8a; margin-top: 2px; }
+.clusterlane-ls2-fit { display: inline-flex; gap: 2px; flex-shrink: 0; }
+.clusterlane-ls2-fit i { width: 7px; height: 7px; border-radius: 50%; background: #e2e8f0; display: inline-block; }
+.clusterlane-ls2-fit i.on { background: #3b82f6; }
+.clusterlane-ls2-row--weak { opacity: .7; }
+.clusterlane-ls2-rankbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 10px; }
+.clusterlane-ls2-rank-state { display: inline-flex; align-items: center; gap: 6px; }
+.clusterlane-ls2-rank-err { color: #b91c1c; }
+
+/* =========================================================
+   Cluster Admin v2 (0.98.083)
+   ========================================================= */
+.clusterlane-ca-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.clusterlane-ca-card-head h3 { margin: 0; font-size: 15px; color: #0f172a; display: flex; align-items: center; }
+.clusterlane-ca-bulk { font-size: 12.5px; color: #cbd5e1; }
+.clusterlane-ca-term { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid #f3f4f6; font-size: 13.5px; cursor: pointer; }
+.clusterlane-ca-term:last-child { border-bottom: 0; }
+.clusterlane-ca-term input { margin: 0; }
+.clusterlane-ca-term-name { display: flex; flex-direction: column; gap: 2px; color: #0f172a; min-width: 0; }
+.clusterlane-ca-fit { font-size: 11.5px; }
+.clusterlane-ca-fit--low { color: #15803d; }
+.clusterlane-ca-fit--mid { color: #b45309; }
+.clusterlane-ca-fit--high { color: #b91c1c; }
+.clusterlane-ca-status { font-size: 12.5px; white-space: nowrap; }
+.clusterlane-ca-status--off { color: #94a3b8; font-size: 12px; }
+.clusterlane-ca-status .clusterlane-ci-health-cell { font-size: 12.5px; }
+.clusterlane-ca-savebar {
+    position: sticky; bottom: 12px; z-index: 50; margin-top: 16px;
+    display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px;
+    padding: 12px 16px; border-radius: 12px; background: #16223a; color: #e2e8f0; font-size: 13.5px;
+    box-shadow: 0 12px 30px rgba(15,23,42,.28);
+}
+.clusterlane-ca-savebar[hidden] { display: none; }
+.clusterlane-ca-savebar strong { color: #fff; }
+.clusterlane-ca-savebar .clusterlane-tm-note { color: #9fb0c8; }
+.clusterlane-ca-savebar-actions { display: flex; gap: 8px; }
+.clusterlane-ca-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 14px 0; }
+.clusterlane-ca-toolbar .clusterlane-tm-btn .clusterlane-g { margin-right: 6px; }
+.clusterlane-ca-pro-note { flex: 1 1 320px; }
+.clusterlane-ca-table td.clusterlane-ca-actions { text-align: right; white-space: nowrap; }
+.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn { height: 28px; padding: 0 10px; text-decoration: none; }
+.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-g { margin: 0; }
+.clusterlane-ca-table > tbody > tr.clusterlane-ca-row { cursor: default; }
+.clusterlane-ca-table > tbody > tr.is-editing { background: #eff6ff; box-shadow: inset 3px 0 0 #3b82f6; }
+.clusterlane-ca-table > tbody > tr.clusterlane-ca-editor-row > td { padding: 0 12px 14px; background: #f8fafc; box-shadow: inset 3px 0 0 #3b82f6; }
+.clusterlane-ca-table > tbody > tr.clusterlane-ca-editor-row:hover { background: #f8fafc; }
+@keyframes clusterlane-ca-new { from { background: #dcfce7; } to { background: transparent; } }
+.clusterlane-ca-row--new { animation: clusterlane-ca-new 2.5s ease-out; }
+.clusterlane-ca-editor { margin: 4px 0 16px; padding: 16px; border: 1px solid #bfdbfe; border-radius: 12px; background: #fff; max-width: 760px; }
+.clusterlane-ca-e-label { display: block; font-size: 12.5px; font-weight: 600; color: #334155; margin: 0 0 6px; }
+.clusterlane-ca-e-name { display: block; width: 100%; max-width: 420px; height: 36px; margin: 4px 0 14px; padding: 0 12px; border: 1.5px solid #e2e8f0; border-radius: 9px; font-size: 14px; font-weight: 400; }
+.clusterlane-ca-e-name:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.15); }
+.clusterlane-ca-e-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
+.clusterlane-ca-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 4px 4px 10px; border-radius: 999px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a8a; font-size: 12.5px; }
+.clusterlane-ca-chip button { width: 20px; height: 20px; border: 0; border-radius: 50%; background: transparent; color: #64748b; font-size: 15px; line-height: 1; cursor: pointer; }
+.clusterlane-ca-chip button:hover { background: #dbeafe; color: #b91c1c; }
+.clusterlane-ca-e-search { max-width: 420px; }
+.clusterlane-ca-editor .clusterlane-browse__search input { width: 100%; height: 36px; min-height: 0; margin: 0; padding: 0 12px 0 34px; border: 1.5px solid #e2e8f0; border-radius: 9px; font-size: 13px; }
+.clusterlane-ca-e-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
+.clusterlane-ca-e-err { color: #b91c1c !important; }
+.clusterlane-ca-ideas { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; margin-bottom: 16px; }
+.clusterlane-ca-idea { display: flex; flex-direction: column; padding: 14px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
+.clusterlane-ca-idea--dupe { border-color: #fde68a; }
+.clusterlane-ca-idea--done { border-color: #bbf7d0; background: #f0fdf4; }
+.clusterlane-ca-idea-head { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
+.clusterlane-ca-idea-head strong { color: #0f172a; font-size: 14px; }
+.clusterlane-ca-idea-why { margin: 6px 0; font-size: 12.5px; color: #475569; line-height: 1.5; }
+.clusterlane-ca-idea-warn { margin: 0 0 6px; font-size: 12px; color: #92400e; }
+.clusterlane-ca-idea-list { margin: 4px 0 12px; padding-left: 18px; font-size: 12.5px; color: #334155; }
+.clusterlane-ca-idea-list li { margin: 2px 0; }
+.clusterlane-ca-idea-foot { margin-top: auto; }
+.clusterlane-ca-idea-ok { color: #15803d; font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 4px; }
+@media (max-width: 782px) {
+    .clusterlane-ca-term { grid-template-columns: auto 1fr auto; }
+    .clusterlane-ca-term .clusterlane-checkbox-count { display: none; }
+    .clusterlane-ca-table td.clusterlane-ca-actions { white-space: normal; }
+}
+.clusterlane-ca-status--pending { color: #1d4ed8; font-weight: 600; font-size: 12px; }
+.clusterlane-ca-idea-spans { margin: 0 0 6px; font-size: 12px; color: #1d4ed8; }
+.clusterlane-ca-ideas-hidden { margin: -6px 0 10px; }
+
+/* =========================================================
+   Settings v2 — tabs + save bar (0.98.086)
+   ========================================================= */
+.clusterlane-set-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin: 0 0 18px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; }
+.clusterlane-set-tabs button {
+    display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 16px;
+    border: 0; border-radius: 9px; background: transparent; color: #475569; font-size: 13.5px; font-weight: 600; cursor: pointer;
+}
+.clusterlane-set-tabs button .clusterlane-g { margin: 0; color: #94a3b8; }
+.clusterlane-set-tabs button em { font-style: normal; color: #2563eb; font-size: 18px; line-height: 0; }
+.clusterlane-set-tabs button:hover { background: #f8fafc; color: #0f172a; }
+.clusterlane-set-tabs button.is-active { background: #16223a; color: #fff; }
+.clusterlane-set-tabs button.is-active .clusterlane-g { color: #60a5fa; }
+.clusterlane-set-tabs button.is-active em { color: #60a5fa; }
+.clusterlane-set-tabs button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-set-panel[hidden] { display: none; }
+.clusterlane-set-card { margin-bottom: 16px; }
+.clusterlane-set-card-head { padding: 16px 22px 0; }
+.clusterlane-set-card-head h2 { display: flex; align-items: center; gap: 2px; margin: 0; font-size: 15px; }
+.clusterlane-set-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.clusterlane-set-intro { margin: 0 0 14px; font-size: 13.5px; color: #475569; }
+.clusterlane-set-note { display: flex; align-items: center; gap: 4px; margin: 0 0 6px; padding: 10px 12px; border-radius: 9px; background: #eff6ff; color: #1e3a8a; font-size: 13px; }
+.clusterlane-set-note a { font-weight: 600; }
+.clusterlane-ca-mincount { margin: 0 0 14px; font-size: 13px; color: #334155; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.clusterlane-ca-mincount input { width: 64px; height: 30px; min-height: 0; margin: 0 4px; border: 1.5px solid #e2e8f0; border-radius: 8px; }
+@media (max-width: 782px) {
+    .clusterlane-set-tabs { flex-wrap: nowrap; overflow-x: auto; }
+    .clusterlane-set-tabs button { flex-shrink: 0; padding: 0 12px; }
+}
+
+/* Same gap under the sticky bar on every page (0.98.087): the Dashboard hero
+   and the Article Inspector head had no top margin, so they sat 6 px higher
+   than the 16 px every other page hero uses. */
+.clusterlane-wrap > .clusterlane-hero, .clusterlane-wrap > .clusterlane-inspector-head { margin-top: 16px; }
+
+/* Sticky bar — clearer feedback for Scan / Sync / Metrics (0.98.088)
+   The button shows the state (busy → ✓ / !), and a light card under the
+   buttons says what happened, with numbers and an optional Reload. */
+.clusterlane-stickybar .dashicons.clusterlane-spin { animation: clusterlane-spin .8s linear infinite; }
+.clusterlane-stickybar__action-btn.is-busy { opacity: 1; color: #fff; border-color: rgba(96,165,250,.6); background: rgba(59,130,246,.18); cursor: progress; }
+.clusterlane-stickybar__action-btn.is-done, .clusterlane-stickybar__action-btn.is-done:hover { color: #fff; background: #16a34a; border-color: #16a34a; }
+.clusterlane-stickybar__action-btn.is-failed, .clusterlane-stickybar__action-btn.is-failed:hover { color: #fff; background: #dc2626; border-color: #dc2626; }
+@media (prefers-reduced-motion: reduce) {
+    .clusterlane-stickybar .dashicons.clusterlane-spin { animation: none; }
+}
+.clusterlane-stickybar__toast {
+    left: auto; right: 34px; transform: none; bottom: auto; top: calc(100% - 4px);
+    display: flex; align-items: flex-start; gap: 10px; max-width: min(460px, calc(100vw - 60px)); white-space: normal;
+    padding: 11px 12px 11px 12px; border-radius: 11px;
+    background: #fff; color: #0f172a; font-size: 13px; line-height: 1.45;
+    border: 1px solid #e2e8f0; border-left: 4px solid #3b82f6;
+    box-shadow: 0 14px 34px rgba(15,23,42,.22); pointer-events: auto;
+    animation: clusterlane-toast-in .18s ease-out;
+}
+.clusterlane-stickybar__toast[hidden] { display: none; }
+@keyframes clusterlane-toast-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
+.clusterlane-stickybar__toast--success { border-left-color: #16a34a; }
+.clusterlane-stickybar__toast--error { border-left-color: #dc2626; }
+.clusterlane-stickybar__toast-icon { flex-shrink: 0; color: #3b82f6; font-size: 16px; line-height: 1; margin-top: 1px; }
+.clusterlane-stickybar__toast--success .clusterlane-stickybar__toast-icon { color: #16a34a; }
+.clusterlane-stickybar__toast--error .clusterlane-stickybar__toast-icon { color: #dc2626; }
+.clusterlane-stickybar__toast--info .clusterlane-stickybar__toast-icon .clusterlane-g { animation: clusterlane-spin 1s linear infinite; }
+.clusterlane-stickybar__toast-icon .clusterlane-g { margin: 0; }
+.clusterlane-stickybar__toast-msg { flex: 1; }
+.clusterlane-stickybar__toast-link { background: none; border: 0; padding: 0; margin-left: 2px; color: #1d4ed8; font-weight: 600; text-decoration: underline; cursor: pointer; font-size: 13px; }
+.clusterlane-stickybar__toast-close { flex-shrink: 0; width: 22px; height: 22px; border: 0; border-radius: 50%; background: transparent; color: #94a3b8; font-size: 16px; line-height: 1; cursor: pointer; }
+.clusterlane-stickybar__toast-close:hover { background: #f1f5f9; color: #0f172a; }
+@media (max-width: 782px) {
+    .clusterlane-stickybar__toast { right: 16px; left: 16px; max-width: none; }
+}
+@media (prefers-reduced-motion: reduce) {
+    .clusterlane-stickybar__toast, .clusterlane-stickybar__toast--info .clusterlane-stickybar__toast-icon .clusterlane-g { animation: none; }
+}
+
+/* Scan in the bar (0.98.089): a thin progress line along the dock's bottom
+   edge, a Stop button in the card, and a hint on the Dashboard before the
+   first scan (the old hero buttons are gone). */
+.clusterlane-stickybar.is-scanning .clusterlane-stickybar__inner::after {
+    content: ""; position: absolute; left: 14px; right: 14px; bottom: 0; height: 3px; border-radius: 3px;
+    background: linear-gradient(90deg, #3b82f6 var(--clusterlane-scan-pct, 0%), rgba(255,255,255,.08) var(--clusterlane-scan-pct, 0%));
+    transition: background .3s;
+}
+.clusterlane-stickybar__toast-stop { flex-shrink: 0; height: 26px; padding: 0 10px; border: 1px solid #e2e8f0; border-radius: 7px; background: #fff; color: #b91c1c; font-size: 12px; font-weight: 600; cursor: pointer; }
+.clusterlane-stickybar__toast-stop:hover { background: #fef2f2; border-color: #fecaca; }
+.clusterlane-hero-firstscan { display: flex; align-items: center; gap: 6px; margin: 4px 24px 20px; padding: 10px 14px; border-radius: 10px; background: rgba(59,130,246,.15); color: #dbeafe; font-size: 13.5px; }
+.clusterlane-hero-firstscan .clusterlane-g { transform: rotate(-90deg); color: #60a5fa; }
+
+/* =========================================================
+   Cluster Inspector — Improve this cluster v2 (0.98.090)
+   ========================================================= */
+.clusterlane-improve-summary:empty { display: none; }
+.clusterlane-improve-summary { margin: 0 0 14px; padding: 14px 16px; border-radius: 12px; background: #f8fafc; border: 1px solid #e2e8f0; }
+.clusterlane-improve-first { display: flex; align-items: center; gap: 6px; margin: 0 0 10px; font-size: 14px; font-weight: 600; color: #0f172a; }
+.clusterlane-improve-first .clusterlane-g { color: #2563eb; margin: 0; }
+.clusterlane-improve-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.clusterlane-improve-chips button { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border: 1px solid #e2e8f0; border-radius: 999px; background: #fff; color: #475569; font-size: 12.5px; font-weight: 600; cursor: pointer; }
+.clusterlane-improve-chips button.is-todo { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
+.clusterlane-improve-chips button:hover { border-color: #93c5fd; }
+.clusterlane-improve-chips button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-improve-block-titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.clusterlane-improve-block-title { font-weight: 700; color: #0f172a; }
+.clusterlane-improve-block-sub { font-size: 12px; font-weight: 400; color: #64748b; line-height: 1.4; }
+.clusterlane-improve-block-header:focus-visible { outline: 2px solid #3b82f6; outline-offset: -2px; border-radius: 10px; }
+.clusterlane-ci-tms-list { margin: 0 0 8px; }
+.clusterlane-tm-in { font-weight: 600; color: #334155; }
+.clusterlane-ci-tms-foot { margin: 6px 2px 0; font-size: 12.5px; color: #94a3b8; }
+.clusterlane-ci-al .clusterlane-ls2-row { margin-bottom: 8px; }
+.clusterlane-ci-al-from { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; color: #64748b; }
+.clusterlane-ci-al-from strong { color: #0f172a; }
+.clusterlane-ci-al-from select { max-width: 380px; height: 30px; min-height: 0; padding: 0 28px 0 8px; border: 1.5px solid #e2e8f0; border-radius: 8px; font-size: 12.5px; color: #0f172a; }
+.clusterlane-gap-help { margin: 0 0 10px; font-size: 12px; color: #64748b; line-height: 1.5; }
+.clusterlane-gap-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
+.clusterlane-gap-draft-done { background: #16a34a !important; border-color: #16a34a !important; color: #fff !important; text-decoration: none; }
+.clusterlane-gap-draft-done .clusterlane-g { margin-right: 4px; }
+.clusterlane-ci-members td.clusterlane-ci-member-actions { text-align: right; white-space: nowrap; }
+.clusterlane-ci-members td.clusterlane-ci-member-actions .clusterlane-tm-btn { height: 28px; padding: 0 8px; }
+.clusterlane-ci-members td.clusterlane-ci-member-actions .clusterlane-g { margin: 0; }
+.clusterlane-ci-member-link { color: #0f172a; font-weight: 600; text-decoration: none; }
+.clusterlane-ci-member-link:hover { color: #1d4ed8; text-decoration: underline; }
+.clusterlane-ci-members > tbody > tr { cursor: default; }
+@media (max-width: 782px) {
+    .clusterlane-ci-members th:nth-child(4), .clusterlane-ci-members td:nth-child(4), .clusterlane-ci-members th:nth-child(6), .clusterlane-ci-members td:nth-child(6) { display: none; }
+    .clusterlane-ci-al-from select { max-width: 100%; width: 100%; }
+}
+
+/* The members table belongs to the OPEN cluster (0.98.092): its own framed box
+   with a heading, lighter/compact rows — it must never be mistaken for the
+   cluster list around the panel. Rows open the article in Article Inspector. */
+.clusterlane-ci-members-box { margin-top: 18px; padding: 14px 16px 16px; border-radius: 12px; background: #f8fafc; border: 1px solid #e2e8f0; }
+.clusterlane-ci-members-title { display: flex; align-items: center; gap: 2px; margin: 0; font-size: 13.5px; font-weight: 700; color: #0f172a; }
+.clusterlane-ci-members-title span { color: #94a3b8; font-weight: 600; margin-left: 4px; }
+.clusterlane-ci-members-hint { margin: 2px 0 10px; font-size: 12px; color: #64748b; }
+.clusterlane-ci-members-wrap { background: #fff; }
+.clusterlane-ci-members { font-size: 12.5px; }
+.clusterlane-ci-members > thead > tr > th { background: #f1f5f9; font-size: 11.5px; padding: 7px 10px; }
+.clusterlane-ci-members > tbody > tr > td { padding: 7px 10px; }
+.clusterlane-ci-members > tbody > tr.clusterlane-ci-member-row { cursor: pointer; }
+.clusterlane-ci-members > tbody > tr.clusterlane-ci-member-row:hover { background: #eff6ff; }
+.clusterlane-ci-members > tbody > tr.clusterlane-ci-member-row:focus-visible { outline: 2px solid #3b82f6; outline-offset: -2px; }
+.clusterlane-ci-member-link { font-weight: 500; }
+
+/* ── 0.98.093 Cluster Inspector: Focus next + List | Map ───────────────
+   Focus next = three computed "start here" cards above the cluster card.
+   The flow map is no longer its own card: it is the Map view of the
+   cluster card and follows the same filters. */
+.clusterlane-ci-focus { margin-top: 16px; padding: 16px 24px 20px; }
+.clusterlane-ci-focus-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
+.clusterlane-ci-focus-head .clusterlane-ai-section-title { margin: 0; padding: 0; border: 0; }
+.clusterlane-ci-focus-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
+.clusterlane-ci-focus-card {
+    --clusterlane-hn: #94a3b8;
+    display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
+    padding: 12px 14px 12px 16px; text-align: left; cursor: pointer;
+    background: #fff; border: 1px solid #e2e8f0; border-left: 3px solid var(--clusterlane-hn); border-radius: 12px;
+    font: inherit; color: #0f172a;
+    transition: border-color .12s, box-shadow .12s, transform .12s;
+}
+.clusterlane-ci-focus-card:hover { border-color: #cbd5e1; border-left-color: var(--clusterlane-hn); box-shadow: 0 4px 14px rgba(15,23,42,.08); transform: translateY(-1px); }
+.clusterlane-ci-focus-card:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-ci-focus-top { display: flex; align-items: center; gap: 10px; width: 100%; font-size: 12.5px; }
+.clusterlane-ci-focus-rank { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: #16223a; color: #fff; font-size: 11px; font-weight: 700; }
+.clusterlane-ci-focus-size { margin-left: auto; color: #94a3b8; font-size: 12px; }
+.clusterlane-ci-focus-name { font-size: 14px; font-weight: 700; line-height: 1.3; }
+.clusterlane-ci-focus-why { font-size: 12.5px; color: #475569; line-height: 1.45; }
+.clusterlane-ci-focus-go { margin-top: auto; padding-top: 4px; font-size: 12px; font-weight: 600; color: #2563eb; }
+.clusterlane-ci-focus-go .clusterlane-g { margin: 0 0 0 2px; }
+.clusterlane-ci-focus-done { margin: 0; font-size: 13px; color: #15803d; }
+/* Focus next on the Dashboard (0.98.187) — same cards as Cluster Inspector */
+.clusterlane-dash-focus { margin-top: 16px; padding: 16px 24px 20px; }
+.clusterlane-dash-focus__head { display: flex; align-items: center; gap: 6px 12px; flex-wrap: wrap; margin-bottom: 12px; }
+.clusterlane-dash-focus .clusterlane-dash-focus__head h2 { display: flex; align-items: center; gap: 6px; margin: 0; padding: 0; border: 0; font-size: 14px; font-weight: 700; color: #0f172a; text-transform: none; letter-spacing: 0; }
+.clusterlane-dash-focus__head .clusterlane-tm-note { margin-left: auto; }
+a.clusterlane-ci-focus-card { text-decoration: none; }
+a.clusterlane-ci-focus-card:hover, a.clusterlane-ci-focus-card:focus { color: #0f172a; }
+@media (max-width: 782px) { .clusterlane-dash-focus { padding: 14px 16px 16px; } .clusterlane-dash-focus__head .clusterlane-tm-note { margin-left: 0; } }
+.clusterlane-ci-focus-done .clusterlane-g { color: #16a34a; }
+.clusterlane-ci-view .clusterlane-seg-btn { display: inline-flex; align-items: center; }
+.clusterlane-ci-view .clusterlane-seg-btn .clusterlane-g { margin-right: .35em; }
+.clusterlane-ci-map-note { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 12px; margin: 0 0 8px; font-size: 11.5px; color: #94a3b8; }
+#clusterlane-ci-map-cap { color: #b45309; }
+@media (max-width: 782px) {
+    .clusterlane-ci-focus { padding: 14px 16px 16px; }
+    .clusterlane-ci-focus-grid { grid-template-columns: 1fr; }
+}
+
+/* Site Analysis: cluster names open that cluster in Cluster Inspector */
+a.clusterlane-sa-chip { text-decoration: none; cursor: pointer; transition: filter .12s, box-shadow .12s; }
+a.clusterlane-sa-chip:hover { filter: brightness(.96); box-shadow: 0 0 0 2px rgba(15,23,42,.08); }
+a.clusterlane-sa-chip:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+.clusterlane-sa-cluster-link { color: inherit; font-weight: 600; text-decoration: underline; text-decoration-color: #93c5fd; text-underline-offset: 2px; }
+.clusterlane-sa-cluster-link:hover { color: #1d4ed8; text-decoration-color: currentColor; }
+
+/* ── 0.98.094 Dashboard: Site map (treemap of all clusters) ───────────
+   Area = articles, colour = health or orphan share (--sm-* set per tile).
+   Tiles reuse .clusterlane-dash-cluster-row--graph → click loads the Cluster Graph. */
+.clusterlane-sm-card { margin-top: 20px; }
+.clusterlane-sm-card .clusterlane-site-analysis-title strong + .clusterlane-tip { margin-left: 4px; }
+.clusterlane-sm-card .clusterlane-dash-graph-controls { display: flex; align-items: center; gap: 8px; }
+.clusterlane-sm-body { position: relative; }
+.clusterlane-sm-canvas {
+    position: relative; height: 320px; border-radius: 10px; overflow: hidden;
+    background: #f8fafc; border: 1px solid #e2e8f0;
+}
+.clusterlane-sm-empty { margin: 0; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #94a3b8; font-size: 13px; }
+.clusterlane-sm-tile {
+    position: absolute; box-sizing: border-box; overflow: hidden;
+    display: flex; flex-direction: column; justify-content: flex-start; gap: 2px;
+    padding: 8px 10px; border: 2px solid #fff; border-radius: 8px;
+    background: var(--sm-bg, #f1f5f9); color: var(--sm-text, #334155);
+    box-shadow: inset 3px 0 0 var(--sm-edge, #94a3b8);
+    text-decoration: none; cursor: pointer;
+    transition: filter .12s, box-shadow .12s;
+}
+.clusterlane-sm-tile:hover, .clusterlane-sm-tile:focus-visible { filter: brightness(.95) saturate(1.1); color: var(--sm-text, #334155); }
+.clusterlane-sm-tile:focus-visible { outline: 2px solid #3b82f6; outline-offset: -4px; }
+.clusterlane-sm-tile.is-current { box-shadow: inset 3px 0 0 var(--sm-edge), inset 0 0 0 2px #16223a; }
+.clusterlane-sm-name { font-size: 12.5px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.clusterlane-sm-meta { margin-top: auto; font-size: 11.5px; opacity: .8; line-height: 1.3; overflow: hidden; }
+.clusterlane-sm-tile--md .clusterlane-sm-name { font-size: 13.5px; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.clusterlane-sm-tile--sm { padding: 5px 7px; }
+.clusterlane-sm-tile--sm .clusterlane-sm-name { font-size: 11px; }
+.clusterlane-sm-tile--xs { padding: 0; }
+.clusterlane-sm-tile--rest { background: #f1f5f9; color: #64748b; box-shadow: none; border-style: solid; justify-content: center; align-items: center; }
+.clusterlane-sm-tile--rest .clusterlane-sm-name { font-weight: 600; }
+.clusterlane-sm-tip {
+    position: absolute; z-index: 5; display: none; pointer-events: none; max-width: 320px;
+    background: #1e2535; color: #e2e8f0; border-radius: 7px; padding: 7px 10px;
+    font-size: 12px; line-height: 1.4; box-shadow: 0 4px 14px rgba(0,0,0,.3);
+}
+.clusterlane-sm-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; margin-top: 10px; }
+.clusterlane-sm-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 11.5px; color: #64748b; }
+.clusterlane-sm-legend-title { font-weight: 600; color: #475569; }
+.clusterlane-sm-legend-item { display: inline-flex; align-items: center; gap: 6px; }
+.clusterlane-sm-swatch { width: 12px; height: 12px; border-radius: 3px; border: 2px solid; box-sizing: border-box; }
+.clusterlane-sm-legend-note { color: #94a3b8; }
+.clusterlane-sm-takeaway { margin: 0; font-size: 12.5px; font-weight: 600; color: #334155; }
+.clusterlane-sm-takeaway .clusterlane-g { color: #64748b; }
+@media (max-width: 782px) {
+    .clusterlane-sm-canvas { height: 420px; }
+    .clusterlane-sm-foot { flex-direction: column; align-items: flex-start; }
+}
+
+/* ── 0.98.095 Article Inspector hero: cluster terms + search preview ───
+   Only terms that are clusters show; the rest fold into "+N terms".
+   The search preview reads SEO title/description via SeoMeta. */
+.clusterlane-ai-terms { margin-top: 8px; }
+.clusterlane-ai-terms-more {
+    display: inline-block; margin-top: 4px; padding: 2px 9px; border-radius: 12px; cursor: pointer;
+    font: inherit; font-size: 11px; font-weight: 600; line-height: inherit;
+    background: transparent; color: var(--ih-text, #64748b); border: 1px dashed rgba(255,255,255,.22);
+}
+.clusterlane-ai-terms-more:hover { color: #fff; border-color: rgba(255,255,255,.4); }
+.clusterlane-ai-terms-more:focus-visible { outline: 2px solid var(--ih-node, #3b82f6); outline-offset: 2px; }
+.clusterlane-ai-terms-rest[hidden] { display: none; }
+.clusterlane-serp {
+    margin-top: 14px; max-width: 600px; padding: 12px 16px 14px; border-radius: 12px;
+    background: rgba(15, 23, 42, .55); border: 1px solid rgba(255,255,255,.12);
+    -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
+    font-family: Arial, Helvetica, sans-serif; line-height: 1.45;
+}
+.clusterlane-serp__bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 10px; font-family: inherit; }
+.clusterlane-serp__label { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--ih-muted, #7c8ea8); margin-right: 4px; }
+.clusterlane-serp__chip { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; line-height: 18px; }
+.clusterlane-serp__chip--ok { background: rgba(34,197,94,.16);  color: #86efac; }
+.clusterlane-serp__chip--warn { background: rgba(245,158,11,.18); color: #fcd34d; }
+.clusterlane-serp__chip--bad { background: rgba(239,68,68,.2);   color: #fca5a5; }
+.clusterlane-serp__src { margin-left: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 11px; color: var(--ih-muted, #7c8ea8); text-decoration: none; }
+a.clusterlane-serp__src:hover { color: #fff; text-decoration: underline; }
+.clusterlane-serp__site { display: flex; align-items: center; gap: 10px; }
+.clusterlane-serp__fav { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #303134; border: 1px solid rgba(255,255,255,.14); color: #e8eaed; font-size: 12px; font-weight: 700; flex-shrink: 0; }
+.clusterlane-serp__site > span:last-child { display: flex; flex-direction: column; min-width: 0; }
+.clusterlane-serp__name { font-size: 14px; color: #dadce0; line-height: 1.3; }
+.clusterlane-serp__url { font-size: 12px; color: #bdc1c6; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+/* Title: one line, cut like a search engine does; description: two lines. */
+.clusterlane-serp__title { margin-top: 6px; font-size: 20px; line-height: 1.3; color: #99c3ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.clusterlane-serp__desc { margin-top: 3px; font-size: 14px; line-height: 1.58; color: #bdc1c6; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.clusterlane-serp__desc--none { font-style: italic; color: #9aa0a6; }
+
+/* Settings: SEO field check */
+.clusterlane-seo-check p { margin: 0 0 4px; font-size: 13px; color: #334155; }
+.clusterlane-seo-check .clusterlane-g { color: #16a34a; }
+.clusterlane-seo-check-icon.is-warn .clusterlane-g { color: #d97706; }
+.clusterlane-seo-check-counts strong { color: #b45309; }
+@media (max-width: 782px) {
+    .clusterlane-serp { max-width: none; }
+    .clusterlane-serp__title { font-size: 17px; }
+    .clusterlane-serp__src { margin-left: 0; }
+}
+
+/* ── 0.98.096 AI suggestions for SEO title & description ─────────────
+   Suggest button in the search preview; panel below it in the hero. */
+.clusterlane-serp__ai {
+    display: inline-flex; align-items: center; gap: 2px; height: 22px; padding: 0 9px; margin-left: 8px;
+    border-radius: 999px; border: 1px solid rgba(147,197,253,.35); background: rgba(59,130,246,.18); color: #bfdbfe;
+    font: 600 11px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer;
+}
+.clusterlane-serp__ai:hover { background: rgba(59,130,246,.32); color: #fff; }
+.clusterlane-serp__ai:focus-visible { outline: 2px solid var(--ih-node, #60a5fa); outline-offset: 2px; }
+.clusterlane-serp__ai .clusterlane-g { margin-right: 3px; }
+.clusterlane-serp__src { margin-left: auto; }
+.clusterlane-serp__src + .clusterlane-serp__ai { margin-left: 8px; }
+.clusterlane-serp__ai + .clusterlane-serp__ai { margin-left: 6px; }
+.clusterlane-serp__ai:disabled { opacity: .6; cursor: progress; }
+/* AI review under the search preview (0.98.196) — the dark card's version of SEO Titles' review line */
+.clusterlane-serp__review { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-top: 10px; padding: 8px 10px; border-radius: 8px;
+    background: rgba(124,58,237,.16); border: 1px solid rgba(196,181,253,.28); font: 12.5px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #e2e8f0; }
+.clusterlane-serp__review.is-stale { background: rgba(255,255,255,.05); border-color: rgba(255,255,255,.12); color: #94a3b8; }
+.clusterlane-serp__review--err { background: rgba(220,38,38,.16); border-color: rgba(252,165,165,.35); color: #fecaca; }
+.clusterlane-serp__rv-tag { display: inline-flex; align-items: center; align-self: center; font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #c4b5fd; }
+.clusterlane-serp__rv-tag .clusterlane-g { width: 12px; height: 12px; margin-right: 4px; }
+.clusterlane-serp__rv b { font-weight: 700; color: #86efac; }
+.clusterlane-serp__rv--warn b { color: #fcd34d; }
+.clusterlane-serp__rv--bad b { color: #fca5a5; }
+.clusterlane-serp__rv-stale { font-style: italic; }
+.clusterlane-serp__rv-pro { margin: 8px 0 0; }
+/* Changed by Clusterlane → Restore original (0.98.197) */
+.clusterlane-serp__orig { margin-top: 8px; font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #94a3b8; }
+.clusterlane-serp__orig .clusterlane-g { width: 13px; height: 13px; margin-right: 4px; vertical-align: -2px; color: #86efac; }
+.clusterlane-serp__orig .clusterlane-seo-ai__link { margin-left: 4px; }
+.clusterlane-seot__origchip { background: #eff6ff; color: #1d4ed8; cursor: help; }
+.clusterlane-seot__keep { display: flex; align-items: center; gap: 6px; margin: 0 0 10px; font-size: 12.5px; color: #64748b; }
+.clusterlane-seot__keep .clusterlane-g { width: 14px; height: 14px; margin: 0; color: #16a34a; flex-shrink: 0; }
+.clusterlane-seo-ai[hidden] { display: none; }
+.clusterlane-seo-ai {
+    margin-top: 10px; max-width: 600px; padding: 12px 14px 14px; border-radius: 12px;
+    background: rgba(15,23,42,.72); border: 1px solid rgba(255,255,255,.12);
+    -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
+    color: var(--ih-text-hi, #f1f5f9); font-size: 13px; line-height: 1.45;
+}
+.clusterlane-seo-ai .clusterlane-pro-example-banner { margin-bottom: 10px; }
+.clusterlane-seo-ai__head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.clusterlane-seo-ai__head strong { display: inline-flex; align-items: center; font-size: 13px; }
+.clusterlane-seo-ai__meta { font-size: 11.5px; color: var(--ih-muted, #94a3b8); }
+.clusterlane-seo-ai__link { background: none; border: 0; padding: 0; color: #93c5fd; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; }
+.clusterlane-seo-ai__link:hover { color: #fff; text-decoration: underline; }
+.clusterlane-seo-ai__head .clusterlane-seo-ai__link { margin-left: auto; }
+.clusterlane-seo-ai__close { background: none; border: 0; color: var(--ih-muted, #94a3b8); font-size: 18px; line-height: 1; cursor: pointer; padding: 0 2px; }
+.clusterlane-seo-ai__head .clusterlane-seo-ai__link + .clusterlane-seo-ai__close { margin-left: 4px; }
+.clusterlane-seo-ai__head .clusterlane-seo-ai__meta + .clusterlane-seo-ai__close { margin-left: auto; }
+.clusterlane-seo-ai__close:hover { color: #fff; }
+.clusterlane-seo-ai__status:empty { display: none; }
+.clusterlane-seo-ai__status { margin-top: 8px; padding: 7px 10px; border-radius: 8px; background: rgba(255,255,255,.06); font-size: 12.5px; }
+.clusterlane-seo-ai__status--ok { background: rgba(34,197,94,.14); color: #bbf7d0; }
+.clusterlane-seo-ai__status--bad { background: rgba(239,68,68,.16); color: #fecaca; }
+.clusterlane-seo-ai__status code { background: rgba(255,255,255,.1); color: inherit; padding: 0 4px; border-radius: 4px; font-size: 11.5px; }
+.clusterlane-seo-ai__status .clusterlane-g { color: #4ade80; }
+.clusterlane-seo-ai__note { margin: 8px 0 0; font-size: 12.5px; color: #fcd34d; }
+.clusterlane-seo-ai__note a { color: #93c5fd; }
+.clusterlane-seo-ai__cards { display: grid; gap: 10px; margin-top: 10px; }
+.clusterlane-seo-ai__card { padding: 10px 12px; border-radius: 10px; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.1); }
+.clusterlane-seo-ai__card.is-applied { border-color: rgba(74,222,128,.55); box-shadow: 0 0 0 1px rgba(74,222,128,.25) inset; }
+.clusterlane-seo-ai__lbl { display: flex; justify-content: space-between; align-items: center; margin: 2px 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--ih-muted, #94a3b8); }
+.clusterlane-seo-ai__lbl + input, .clusterlane-seo-ai__lbl + textarea { margin-bottom: 6px; }
+#clusterlane-ai-head .clusterlane-seo-ai__t, #clusterlane-ai-head .clusterlane-seo-ai__d {
+    width: 100%; box-sizing: border-box; margin: 0; padding: 6px 9px; border-radius: 8px;
+    border: 1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.06); color: #f8fafc;
+    font: 14px/1.45 Arial, Helvetica, sans-serif; box-shadow: none; resize: vertical;
+}
+#clusterlane-ai-head .clusterlane-seo-ai__t { color: #99c3ff; font-size: 16px; height: 34px; min-height: 0; }
+#clusterlane-ai-head .clusterlane-seo-ai__d { color: #d1d5db; }
+#clusterlane-ai-head .clusterlane-seo-ai__t:focus, #clusterlane-ai-head .clusterlane-seo-ai__d:focus { outline: none; border-color: var(--ih-node, #60a5fa); box-shadow: 0 0 0 3px rgba(96,165,250,.2); }
+.clusterlane-seo-ai__count { font-size: 11px; font-weight: 600; letter-spacing: 0; text-transform: none; padding: 0 7px; border-radius: 999px; }
+.clusterlane-seo-ai__count--ok { background: rgba(34,197,94,.16);  color: #86efac; }
+.clusterlane-seo-ai__count--warn { background: rgba(245,158,11,.18); color: #fcd34d; }
+.clusterlane-seo-ai__count--bad { background: rgba(239,68,68,.2);   color: #fca5a5; }
+.clusterlane-seo-ai__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
+.clusterlane-seo-ai__btn {
+    height: 28px; padding: 0 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18); background: transparent;
+    color: #e2e8f0; font: 600 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer;
+}
+.clusterlane-seo-ai__btn:hover { background: rgba(255,255,255,.08); color: #fff; }
+.clusterlane-seo-ai__btn--primary { background: #fff; border-color: #fff; color: #16223a; }
+.clusterlane-seo-ai__btn--primary:hover { background: #e2e8f0; border-color: #e2e8f0; color: #16223a; }
+.clusterlane-seo-ai__btn:disabled { opacity: .6; cursor: default; }
+.clusterlane-seo-ai__hint { font-size: 11.5px; color: var(--ih-muted, #94a3b8); }
+.clusterlane-seo-ai__loading, .clusterlane-seo-ai__error { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--ih-text, #cbd5e1); }
+.clusterlane-seo-ai__loading .clusterlane-g { animation: clusterlaneSeoPulse 1.2s ease-in-out infinite; color: #93c5fd; }
+.clusterlane-seo-ai__error { color: #fecaca; justify-content: space-between; }
+@keyframes clusterlaneSeoPulse { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
+
+/* Settings → SEO card: AI suggestions sub-section */
+.clusterlane-seo-ai-settings { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f1f5f9; }
+.clusterlane-seo-ai-head { display: flex; align-items: center; margin: 0 0 4px; font-size: 13.5px; font-weight: 700; color: #0f172a; }
+#clusterlane-set-seo .clusterlane-pro-note { margin-top: 14px; }
+@media (max-width: 782px) {
+    .clusterlane-seo-ai { max-width: none; }
+}
+
+/* Example banner inside the hero: the hero's white <strong> rule must not apply. */
+.clusterlane-inspector-head__meta .clusterlane-pro-example-banner strong { color: inherit; }
+
+/* Dashboard card headers (Site map, Cluster Graph, Site Intelligence Analysis):
+   title + subtitle on the left, controls right-aligned before the arrow,
+   always on one row on desktop. */
+@media (min-width: 783px) {
+    #clusterlane-sm-card > .clusterlane-site-analysis-header, #clusterlane-dash-graph-card > .clusterlane-site-analysis-header, #clusterlane-site-analysis-card > .clusterlane-site-analysis-header { flex-wrap: nowrap; }
+    #clusterlane-sm-card .clusterlane-site-analysis-title, #clusterlane-dash-graph-card .clusterlane-site-analysis-title, #clusterlane-site-analysis-card .clusterlane-site-analysis-title { flex: 1 1 auto; min-width: 0; }
+    #clusterlane-sm-card .clusterlane-dash-graph-controls, #clusterlane-dash-graph-card .clusterlane-dash-graph-controls, #clusterlane-site-analysis-card .clusterlane-site-analysis-actions { margin-left: auto; flex: 0 0 auto; }
+}
+
+/* Site map: "?" right after the title, subtitle on its own line. */
+.clusterlane-sm-card .clusterlane-site-analysis-title strong { display: inline; }
+.clusterlane-sm-card .clusterlane-site-analysis-sub { display: block; }
+
+/* ── 0.98.097 Article Inspector: Improve this article ─────────────────
+   One checklist, most important first; the rest of the page is the
+   evidence ("The details behind it"). Rows reuse the Text Match rows. */
+.clusterlane-ai-improve { margin-bottom: 20px; }
+.clusterlane-ai-improve__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
+.clusterlane-ai-improve__head .clusterlane-ai-section-title { margin: 0; padding: 0; border: 0; }
+.clusterlane-ai-improve__sum { font-size: 12.5px; color: #64748b; }
+.clusterlane-ai-improve__sum strong { color: #0f172a; }
+.clusterlane-ai-improve__list { display: grid; gap: 10px; }
+.clusterlane-ai-improve__loading { margin: 0; font-size: 13px; color: #94a3b8; }
+.clusterlane-ai-improve__done { margin: 0; display: flex; align-items: center; gap: 6px; font-size: 13.5px; color: #15803d; font-weight: 600; }
+.clusterlane-ai-improve__done .clusterlane-g { color: #16a34a; }
+.clusterlane-ai-improve__item {
+    --imp: #94a3b8;
+    padding: 12px 14px 12px 16px; border: 1px solid #e2e8f0; border-left: 3px solid var(--imp); border-radius: 12px; background: #fff;
+}
+.clusterlane-ai-improve__item--high { --imp: #dc2626; }
+.clusterlane-ai-improve__item--medium { --imp: #d97706; }
+.clusterlane-ai-improve__item--low { --imp: #94a3b8; }
+.clusterlane-ai-improve__item-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.clusterlane-ai-improve__item-head strong { font-size: 14px; color: #0f172a; }
+.clusterlane-ai-improve__node { width: 10px; height: 10px; border-radius: 50%; background: var(--imp); box-shadow: 0 0 0 3px color-mix(in srgb, var(--imp) 20%, transparent); flex-shrink: 0; }
+.clusterlane-ai-improve__first { font-size: 10.5px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #fff; background: #16223a; border-radius: 999px; padding: 2px 8px; }
+.clusterlane-ai-improve__why { margin: 4px 0 0 18px; font-size: 12.5px; color: #475569; line-height: 1.5; }
+.clusterlane-ai-improve__lead { margin: 10px 0 6px 18px; font-size: 12px; color: #64748b; }
+.clusterlane-ai-improve__item .clusterlane-tm-group { margin: 6px 0 0 18px; }
+.clusterlane-ai-improve__more { margin: 6px 0 0 18px; font-size: 12px; color: #64748b; }
+.clusterlane-ai-improve__actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0 18px; }
+.clusterlane-ai-improve__btn { display: inline-flex; align-items: center; text-decoration: none; }
+.clusterlane-ai-improve__btn .clusterlane-g { margin-right: 5px; }
+.clusterlane-ai-improve__tips { margin: 6px 0 0 36px; padding: 0; font-size: 12.5px; color: #475569; line-height: 1.55; list-style: disc; }
+.clusterlane-ai-improve__tips li { margin: 0 0 3px; }
+
+/* "More ideas from AI" — the AI analysis, now a block at the end of the panel. */
+.clusterlane-ai-improve__ai { margin-top: 16px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
+.clusterlane-ai-improve__ai .clusterlane-ai-section-title { font-size: 13.5px; }
+.clusterlane-ai-details-head { margin: 26px 0 12px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #94a3b8; }
+
+/* Link Suggestions: the pair opened from Article Inspector */
+.clusterlane-ls2-row--focus { box-shadow: 0 0 0 2px #3b82f6; border-radius: 10px; }
+@media (max-width: 782px) {
+    .clusterlane-ai-improve__why, .clusterlane-ai-improve__lead, .clusterlane-ai-improve__item .clusterlane-tm-group, .clusterlane-ai-improve__more, .clusterlane-ai-improve__actions { margin-left: 0; }
+    .clusterlane-ai-improve__tips { margin-left: 18px; }
+}
+.clusterlane-ai-improve__ai .clusterlane-ai-analysis-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.clusterlane-ai-improve__ai .clusterlane-ai-analysis-title-row { display: flex; align-items: center; gap: 8px; }
+.clusterlane-ai-improve__ai .clusterlane-ai-section-title { border: 0; padding: 0; margin: 0; }
+.clusterlane-ai-improve__ai .clusterlane-ai-badge { display: none; }
+.clusterlane-ai-improve__ai > p { margin: 4px 0 0 !important; }
+.clusterlane-ai-improve__ai .clusterlane-action-btn { height: 32px; padding: 0 14px; font-size: 12.5px; }
+
+/* ── 0.98.098 Article Inspector: vitals row + Article map tabs ─────── */
+.clusterlane-ai-vitals {
+    display: flex; flex-wrap: wrap; align-items: center; gap: 6px 18px;
+    margin: 0 0 16px; padding: 12px 18px; border-radius: 14px;
+    background: #fff; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(15,23,42,.05);
+}
+.clusterlane-ai-vitals[hidden] { display: none; }
+.clusterlane-ai-vital { display: inline-flex; align-items: baseline; gap: 5px; font-size: 12.5px; color: #64748b; white-space: nowrap; }
+.clusterlane-ai-vital strong { font-size: 17px; font-weight: 800; color: #0f172a; letter-spacing: -.01em; }
+.clusterlane-ai-vital--good strong { color: #059669; }
+.clusterlane-ai-vital--fair strong { color: #d97706; }
+.clusterlane-ai-vital--weak strong { color: #dc2626; }
+.clusterlane-ai-vital--role { align-items: center; gap: 7px; white-space: normal; }
+.clusterlane-ai-vital--role strong { font-size: 12.5px; font-weight: 700; }
+.clusterlane-ai-vital--role em { font-style: normal; color: #94a3b8; }
+.clusterlane-ai-vital-sep { width: 1px; height: 18px; background: #e2e8f0; }
+.clusterlane-ai-graph-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 10px; }
+.clusterlane-ai-graph-tabs[hidden] { display: none; }
+.clusterlane-ai-graph-tabs button {
+    display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px;
+    border: 1px solid #e2e8f0; background: #fff; color: #334155; font: 600 12.5px/1 inherit; cursor: pointer;
+}
+.clusterlane-ai-graph-tabs button:hover { border-color: #cbd5e1; background: #f8fafc; }
+.clusterlane-ai-graph-tabs button.is-active { background: #16223a; border-color: #16223a; color: #fff; }
+.clusterlane-ai-graph-tabs button em { font-style: normal; font-weight: 500; opacity: .7; }
+.clusterlane-ai-graph-tabs button .clusterlane-g { margin-right: 0; }
+.clusterlane-ai-graph-tab-node { width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; }
+.clusterlane-ai-graph-tab-node--good { background: #22c55e; box-shadow: 0 0 0 3px rgba(34,197,94,.2); }
+.clusterlane-ai-graph-tab-node--fair { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245,158,11,.2); }
+.clusterlane-ai-graph-tab-node--weak { background: #ef4444; box-shadow: 0 0 0 3px rgba(239,68,68,.2); }
+.clusterlane-ai-graph-tip { background: #1e2535; color: #e2e8f0; border-radius: 7px; padding: 7px 10px; font-size: 12px; line-height: 1.4; max-width: 280px; box-shadow: 0 4px 14px rgba(0,0,0,.3); }
+.clusterlane-ai-graph-tip strong { display: block; color: #fff; }
+.clusterlane-ai-graph-tip span { color: #94a3b8; }
+.clusterlane-ai-graph-ci { margin-left: auto; font-size: 12px; font-weight: 600; color: #2563eb; text-decoration: none; }
+.clusterlane-ai-graph-ci:hover { text-decoration: underline; }
+@media (max-width: 782px) {
+    .clusterlane-ai-vital-sep { display: none; }
+    .clusterlane-ai-vitals { gap: 6px 14px; }
+}
+
+/* ── 0.98.099 Article Inspector: details = map first, then three even cards ── */
+#clusterlane-ai-inspector .clusterlane-ai-graph-card { margin-top: 0; }
+.clusterlane-ai-trio { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 16px; align-items: stretch; }
+.clusterlane-ai-trio__card { margin: 0; min-width: 0; }
+@media (max-width: 1100px) {
+    .clusterlane-ai-trio { grid-template-columns: 1fr 1fr; }
+    .clusterlane-ai-trio__card:last-child { grid-column: 1 / -1; }
+}
+@media (max-width: 782px) {
+    .clusterlane-ai-trio { grid-template-columns: 1fr; }
+}
+.clusterlane-ai-facts { display: grid; gap: 0; }
+.clusterlane-ai-fact { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
+.clusterlane-ai-fact:last-child { border-bottom: 0; }
+.clusterlane-ai-fact__label { color: #64748b; }
+.clusterlane-ai-fact__value { font-weight: 700; color: #0f172a; display: inline-flex; align-items: center; gap: 6px; text-align: right; }
+.clusterlane-ai-fact__value em { font-style: normal; font-weight: 500; color: #94a3b8; font-size: 12px; }
+.clusterlane-ai-links-summary:empty { display: none; }
+.clusterlane-ai-links-terms { margin: 0 0 10px; font-size: 12px; color: #64748b; line-height: 1.5; }
+.clusterlane-ai-trio__card .clusterlane-ai-links-details:first-of-type { margin-top: 2px; }
+.clusterlane-ai-cluster-row--stacked { display: block; }
+.clusterlane-ai-cluster-line { display: flex; align-items: center; gap: 7px; min-width: 0; }
+.clusterlane-ai-cluster-line .clusterlane-ai-cluster-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clusterlane-ai-cluster-line--meta { margin: 5px 0 0 24px; flex-wrap: wrap; gap: 6px; }
+
+/* ── 0.98.100 Article thumbnails (clusterlaneThumb) ───────────────────────────
+   Featured image (thumbnail size) or a letter tile in the role colour. */
+.clusterlane-thumb { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; border-radius: 6px; background: #e2e8f0; vertical-align: middle; }
+.clusterlane-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.clusterlane-thumb--xs { width: 22px; height: 22px; border-radius: 5px; margin-right: 6px; }
+.clusterlane-thumb--sm { width: 28px; height: 28px; margin-right: 8px; }
+.clusterlane-thumb--md { width: 36px; height: 36px; border-radius: 8px; margin-right: 10px; }
+.clusterlane-thumb--none { color: #475569; font: 700 11px/1 system-ui, sans-serif; background: #f1f5f9; box-shadow: inset 0 0 0 1px #e2e8f0; }
+.clusterlane-thumb--md.clusterlane-thumb--none { font-size: 14px; }
+.clusterlane-thumb--pillar { background: #fdeadf; color: #b4491a; box-shadow: inset 0 0 0 1px #f5c3a6; }
+.clusterlane-thumb--support { background: #fdf4d4; color: #86600a; box-shadow: inset 0 0 0 1px #f5dc8a; }
+.clusterlane-thumb--light { background: #edf4e4; color: #4a6b2a; box-shadow: inset 0 0 0 1px #c8dcb0; }
+.clusterlane-thumb--orphan { background: #e4f0f8; color: #245f84; box-shadow: inset 0 0 0 1px #b5d6ea; }
+.clusterlane-browse__titlecell { display: flex; align-items: center; min-width: 0; }
+.clusterlane-browse__titlecell > div { min-width: 0; }
+.clusterlane-ls-result-item { display: flex; align-items: center; }
+.clusterlane-ai-recent-item .clusterlane-thumb { margin-right: 8px; }
+.clusterlane-ai-link-list li { display: flex; align-items: center; }
+.clusterlane-tm-in, .clusterlane-tm-target { align-items: center; }
+.clusterlane-tm-in .clusterlane-thumb, .clusterlane-tm-target .clusterlane-thumb { vertical-align: -6px; }
+.clusterlane-tm-group-src { display: inline-flex; align-items: center; gap: 5px; }
+.clusterlane-ls2-pick .clusterlane-thumb { align-self: center; }
+.clusterlane-stickybar__dropdown-item { display: flex !important; align-items: center; }
+.clusterlane-stickybar__dropdown-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+
+/* Graph tooltips: thumbnail above the title */
+.clusterlane-graph-tip-img { display: block; width: 100%; max-width: 220px; height: 92px; object-fit: cover; border-radius: 5px; margin-bottom: 6px; }
+.clusterlane-ai-graph-tip { max-width: 240px; }
+
+/* Vitals: "out" including category pages — explained on hover */
+.clusterlane-ai-vital-note { border-bottom: 1px dotted #94a3b8; cursor: help; }
+
+/* 0.98.101 Dock search: larger thumbnail, no underline — the row is the link */
+.clusterlane-stickybar__dropdown-item { display: flex; align-items: center; gap: 10px; text-decoration: none !important; }
+.clusterlane-stickybar__dropdown-item .clusterlane-thumb { margin-right: 0; width: 40px; height: 40px; border-radius: 7px; }
+.clusterlane-stickybar__dropdown-item .clusterlane-stickybar__dropdown-title { text-decoration: none; line-height: 1.35; color: #0f172a; font-weight: 500; }
+.clusterlane-stickybar__dropdown-item:hover .clusterlane-stickybar__dropdown-title, .clusterlane-stickybar__dropdown-item:focus-visible .clusterlane-stickybar__dropdown-title { color: #1d4ed8; }
+
+/* 0.98.102 Internal links lists: like the dock search — larger thumb, no
+   underline, row opens the article in Article Inspector, arrow = on the site. */
+.clusterlane-ai-link-list li { gap: 6px; padding: 5px 4px; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow { display: flex; align-items: center; gap: 10px; flex: 1 1 auto; min-width: 0; color: #0f172a; text-decoration: none; border-radius: 6px; padding: 2px; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow .clusterlane-thumb { margin-right: 0; width: 36px; height: 36px; }
+.clusterlane-ai-linkrow__title { font-size: 13px; font-weight: 500; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow:hover, .clusterlane-ai-link-list a.clusterlane-ai-linkrow:focus-visible { text-decoration: none; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow:hover .clusterlane-ai-linkrow__title, .clusterlane-ai-link-list a.clusterlane-ai-linkrow:focus-visible .clusterlane-ai-linkrow__title { color: #1d4ed8; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 6px; color: #94a3b8; text-decoration: none; transform: rotate(-45deg); opacity: 0; transition: opacity .12s, color .12s, background .12s; }
+.clusterlane-ai-link-list li:hover a.clusterlane-ai-linkrow__site, .clusterlane-ai-link-list a.clusterlane-ai-linkrow__site:focus-visible { opacity: 1; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site:hover { color: #1d4ed8; background: #eff6ff; text-decoration: none; }
+.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site .clusterlane-g { margin: 0; }
+@media (hover: none) {
+    .clusterlane-ai-link-list a.clusterlane-ai-linkrow__site { opacity: 1; }
+}
+
+/* 0.98.105 Settings → Advanced → AI cache */
+.clusterlane-aic__table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; }
+.clusterlane-aic__table th { text-align: left; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #94a3b8; padding: 6px 8px; border-bottom: 1px solid #e2e8f0; }
+.clusterlane-aic__table td { padding: 8px; border-bottom: 1px solid #f1f5f9; color: #334155; }
+.clusterlane-aic__table td.clusterlane-aic__n { font-weight: 700; color: #0f172a; }
+.clusterlane-aic__table td.clusterlane-aic__act { text-align: right; }
+.clusterlane-aic__table tr.is-cleared td { color: #94a3b8; }
+.clusterlane-aic__foot { display: flex; align-items: center; gap: 12px; margin: 12px 0 0; }
+.clusterlane-aic__status { font-size: 12.5px; color: #15803d; font-weight: 600; }
+.clusterlane-aic__empty { margin: 4px 0 0; color: #64748b; }
+
+/* 0.98.108 Chosen pillar (Cluster Inspector members) */
+.clusterlane-ci-chosen { display: inline-block; margin-left: 4px; padding: 0 7px; border-radius: 999px; background: #fff7ed; color: #c2410c; font-size: 10.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; line-height: 18px; vertical-align: middle; }
+.clusterlane-ci-pillar-btn { opacity: .55; }
+.clusterlane-ci-member-row:hover .clusterlane-ci-pillar-btn, .clusterlane-ci-pillar-btn:focus-visible { opacity: 1; }
+.clusterlane-ci-pillar-btn.is-chosen { opacity: 1; color: #c2410c; border-color: #fdba74; background: #fff7ed; }
+
+/* 0.98.109 "Mixed" archetype (was "Unclassified" for analysed clusters) */
+.clusterlane-archetype-mixed { background: #eef2f7; color: #475569; }
+.clusterlane-archetype-unclassified { background: #f8fafc; color: #94a3b8; border: 1px dashed #cbd5e1; }
+
+/* 0.98.110 Site Analysis → "go" buttons to the right Clusterlane tool */
+.clusterlane-sa-go-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
+a.clusterlane-sa-go {
+    display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px;
+    background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 12px; font-weight: 600; text-decoration: none; line-height: 1.3;
+}
+a.clusterlane-sa-go:hover, a.clusterlane-sa-go:focus-visible { background: #dbeafe; color: #1e40af; }
+a.clusterlane-sa-go .clusterlane-g { margin: 0; width: 13px; height: 13px; }
+@keyframes clusterlaneFocusFlash { 0% { box-shadow: 0 0 0 0 rgba(59,130,246,.55); } 100% { box-shadow: 0 0 0 14px rgba(59,130,246,0); } }
+.clusterlane-focus-flash { animation: clusterlaneFocusFlash 1.1s ease-out 2; border-radius: 12px; }
+
+/* Example (Free): the "go" buttons are shown but inert */
+.clusterlane-sa-go.is-example { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 999px; background: #f8fafc; border: 1px dashed #cbd5e1; color: #64748b; font-size: 12px; font-weight: 600; line-height: 1.3; cursor: default; }
+.clusterlane-sa-go.is-example .clusterlane-g { margin: 0; width: 13px; height: 13px; }
+
+/* 0.98.118 Cluster Admin: category/tag whose articles aren't a scanned post type */
+.clusterlane-ca-unscanned { display: flex; align-items: center; gap: 5px; grid-column: 2 / -1; margin: -4px 0 0; font-size: 12px; color: #b45309; }
+.clusterlane-ca-unscanned .clusterlane-g { color: #d97706; margin: 0; }
+
+/* =========================================================
+   REPORT PAGE (0.98.125) — .clusterlane-rp
+   A calm, printable summary: dark header with period + KPIs,
+   then white cards. Charts are inline SVG styled here.
+========================================================= */
+.clusterlane-rp {
+    --clusterlane-rp-ink: #16223a;
+    --clusterlane-rp-pillar: #e76c2e; --clusterlane-rp-support: #f5c842; --clusterlane-rp-light: #90b865; --clusterlane-rp-orphan: #5ba4cf;
+}
+.clusterlane-rp * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+.clusterlane-rp-head { margin-top: 16px; padding: 30px 34px; border-radius: 18px; background: var(--clusterlane-rp-ink); color: #fff; }
+.clusterlane-rp-head__top { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; }
+.clusterlane-rp-head__title { display: flex; align-items: center; gap: 16px; min-width: 0; }
+.clusterlane-rp-head__mark { display: inline-flex; flex-shrink: 0; }
+.clusterlane-rp-head h1 { margin: 2px 0 4px !important; padding: 0 !important; color: #fff !important; font-size: 30px !important; font-weight: 800 !important; line-height: 1.15 !important; letter-spacing: -.01em; }
+.clusterlane-rp-eyebrow { font-size: 12px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #9fb0c8; }
+.clusterlane-rp-head__sub { font-size: 13.5px; color: #cbd5e1; }
+.clusterlane-rp-head__actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.clusterlane-rp-periods { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(255,255,255,.08); }
+.clusterlane-rp-periods a { display: inline-flex; align-items: center; height: 34px; padding: 0 12px; border-radius: 8px; color: #9fb0c8; font-size: 13px; font-weight: 600; text-decoration: none; }
+.clusterlane-rp-periods a:hover { color: #fff; }
+.clusterlane-rp-periods a.is-active { background: rgba(255,255,255,.16); color: #fff; }
+.clusterlane-rp-periods a:focus-visible, .clusterlane-rp-print:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; box-shadow: none; }
+.clusterlane-rp-print { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border: 0; border-radius: 9px; background: #fff; color: var(--clusterlane-rp-ink); font-size: 13px; font-weight: 700; cursor: pointer; }
+.clusterlane-rp-print:hover { background: #e2e8f0; }
+.clusterlane-rp-print .clusterlane-g { margin: 0; }
+.clusterlane-rp-kpis { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
+.clusterlane-rp-kpi { padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,.06); min-width: 0; }
+.clusterlane-rp-kpi__label { font-size: 11.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: #9fb0c8; }
+.clusterlane-rp-kpi__value { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 6px 0 4px; font-size: 30px; font-weight: 800; line-height: 1.1; }
+.clusterlane-rp-kpi__sub { font-size: 12px; color: #9fb0c8; }
+.clusterlane-rp-delta { font-size: 13px; font-weight: 700; white-space: nowrap; }
+.clusterlane-rp-delta--good { color: #86efac; }
+.clusterlane-rp-delta--bad  { color: #fca5a5; }
+.clusterlane-rp-delta--flat { color: #9fb0c8; }
+
+.clusterlane-rp-grid { display: grid; gap: 20px; margin-top: 20px; }
+.clusterlane-rp-grid--trend { grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr); }
+.clusterlane-rp-grid--two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.clusterlane-rp-card { margin-top: 20px; padding: 22px 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: #fff; min-width: 0; box-sizing: border-box; }
+.clusterlane-rp-grid > .clusterlane-rp-card { margin-top: 0; }
+.clusterlane-rp-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
+.clusterlane-rp-card__head h2 { margin: 0; padding: 0; font-size: 16px; font-weight: 700; color: #0f172a; }
+.clusterlane-rp-card__meta { font-size: 12.5px; color: #64748b; }
+.clusterlane-rp-note { margin: 8px 0 0; font-size: 12.5px; color: #64748b; }
+.clusterlane-rp-empty { margin: 0; font-size: 14px; color: #475569; }
+.clusterlane-rp-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12.5px; color: #475569; }
+.clusterlane-rp-legend span { display: inline-flex; align-items: center; gap: 6px; }
+.clusterlane-rp-key { display: inline-block; width: 16px; }
+.clusterlane-rp-key--line { height: 3px; border-radius: 2px; background: var(--clusterlane-rp-ink); }
+.clusterlane-rp-key--dash { height: 0; border-top: 2px dashed var(--clusterlane-rp-orphan); }
+
+/* Trend chart */
+.clusterlane-rp-chart svg { display: block; width: 100%; height: auto; }
+.clusterlane-rp-chart text { font-size: 12px; fill: #64748b; }
+.clusterlane-rp-grid-lines line { stroke: #eef0f3; stroke-width: 1; }
+.clusterlane-rp-area { fill: var(--clusterlane-rp-ink); fill-opacity: .06; }
+.clusterlane-rp-line-health { fill: none; stroke: var(--clusterlane-rp-ink); stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
+.clusterlane-rp-line-orphans { fill: none; stroke: var(--clusterlane-rp-orphan); stroke-width: 2; stroke-dasharray: 5 4; }
+.clusterlane-rp-dot-health { fill: var(--clusterlane-rp-ink); stroke: #fff; stroke-width: 2; }
+.clusterlane-rp-dot-orphans { fill: var(--clusterlane-rp-orphan); stroke: #fff; stroke-width: 2; }
+.clusterlane-rp-chart .clusterlane-rp-end-health { font-size: 13px; font-weight: 700; fill: var(--clusterlane-rp-ink); }
+.clusterlane-rp-chart .clusterlane-rp-end-orphans { font-weight: 700; fill: #245f84; }
+.clusterlane-rp-version { stroke: #94a3b8; stroke-width: 1; stroke-dasharray: 2 3; }
+.clusterlane-rp-chart .clusterlane-rp-version-label { font-size: 11px; }
+.clusterlane-rp-building { display: flex; align-items: center; gap: 14px; padding: 26px 22px; border: 1px dashed #cbd5e1; border-radius: 12px; background: #f8fafc; color: #475569; }
+.clusterlane-rp-building .clusterlane-g { width: 24px; height: 24px; flex-shrink: 0; color: #94a3b8; margin: 0; }
+.clusterlane-rp-building p { margin: 0; font-size: 13.5px; line-height: 1.55; }
+
+/* Work done */
+.clusterlane-rp-work__list { margin: 0; }
+.clusterlane-rp-work__row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 9px 0; border-bottom: 1px solid #f1f5f9; }
+.clusterlane-rp-work__row dt { font-size: 14px; color: #334155; }
+.clusterlane-rp-work__row dd { margin: 0; font-size: 18px; font-weight: 800; color: #0f172a; font-variant-numeric: tabular-nums; }
+.clusterlane-rp-work__row--main dd { font-size: 22px; }
+.clusterlane-rp-work__row--sub { padding: 5px 0 5px 12px; border-bottom: 0; }
+.clusterlane-rp-work__row--sub dt { font-size: 13px; color: #64748b; }
+.clusterlane-rp-work__row--sub dd { font-size: 13px; font-weight: 600; color: #334155; }
+.clusterlane-rp-work__row--sub + .clusterlane-rp-work__row:not(.clusterlane-rp-work__row--sub) { border-top: 1px solid #f1f5f9; }
+.clusterlane-rp-mini-h { margin: 14px 0 6px; font-size: 12px; font-weight: 600; color: #64748b; }
+.clusterlane-rp-work__bars svg { display: block; width: 100%; max-width: 340px; height: auto; }
+.clusterlane-rp-bar { fill: var(--clusterlane-rp-ink); }
+.clusterlane-rp-bars-base { stroke: #e2e8f0; }
+.clusterlane-rp-work__bars text { font-size: 11px; fill: #64748b; }
+.clusterlane-rp-work__bars .clusterlane-rp-bar-max { font-weight: 700; fill: var(--clusterlane-rp-ink); }
+
+/* Clusters table */
+.clusterlane-rp-table-wrap { border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }
+.clusterlane-rp-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13.5px; }
+.clusterlane-rp-table th { padding: 10px 14px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 12px; font-weight: 600; color: #64748b; }
+.clusterlane-rp-table td { padding: 11px 14px; border-bottom: 1px solid #f1f5f9; color: #334155; vertical-align: middle; overflow-wrap: anywhere; }
+.clusterlane-rp-table tbody:last-child tr:last-child td { border-bottom: 0; }
+.clusterlane-rp-table .num { text-align: right; font-variant-numeric: tabular-nums; }
+.clusterlane-rp-table th:nth-child(2) { width: 84px; }
+.clusterlane-rp-table th:nth-child(3) { width: 200px; }
+.clusterlane-rp-table th:nth-child(4) { width: 84px; }
+.clusterlane-rp-table th:nth-child(5) { width: 84px; }
+.clusterlane-rp-table th:nth-child(6) { width: 96px; }
+.clusterlane-rp-cl-name { font-weight: 700; color: #0f172a; text-decoration: none; }
+.clusterlane-rp-cl-name:hover { color: #b4491a; text-decoration: underline; }
+.clusterlane-rp-cl-arch { display: block; margin-top: 2px; font-size: 12px; color: #64748b; }
+.clusterlane-rp-mix { display: flex; gap: 1px; width: 100%; max-width: 180px; height: 10px; border-radius: 5px; overflow: hidden; background: #f1f5f9; }
+.clusterlane-rp-mix__seg { flex-basis: 0; min-width: 3px; }
+.clusterlane-rp-mix__seg--pillar { background: var(--clusterlane-rp-pillar); }
+.clusterlane-rp-mix__seg--support { background: var(--clusterlane-rp-support); }
+.clusterlane-rp-mix__seg--light { background: var(--clusterlane-rp-light); }
+.clusterlane-rp-mix__seg--orphan { background: var(--clusterlane-rp-orphan); }
+.clusterlane-rp-health { font-weight: 700; }
+.clusterlane-rp-health--good { color: #15803d; }
+.clusterlane-rp-health--fair { color: #b45309; }
+.clusterlane-rp-health--weak { color: #dc2626; }
+.clusterlane-rp-health--none { color: #94a3b8; }
+.clusterlane-rp-table .clusterlane-rp-delta--good { color: #15803d; }
+.clusterlane-rp-table .clusterlane-rp-delta--bad { color: #dc2626; }
+.clusterlane-rp-table .clusterlane-rp-delta--flat { color: #94a3b8; font-weight: 600; }
+.clusterlane-rp-bad { color: #dc2626; font-weight: 700; }
+.clusterlane-rp-muted { color: #94a3b8; }
+.clusterlane-rp-table-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px 16px; flex-wrap: wrap; margin-top: 12px; }
+.clusterlane-rp-legend--roles { margin-left: auto; }
+.clusterlane-rp-link { padding: 0; border: 0; background: none; color: var(--clusterlane-rp-ink); font-size: 13px; font-weight: 600; text-decoration: underline; cursor: pointer; }
+.clusterlane-rp-link:hover { color: #b4491a; }
+.clusterlane-rp-link:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
+
+/* Composition */
+.clusterlane-rp-comp { display: grid; grid-template-columns: 210px minmax(0, 1fr) 280px; gap: 14px 22px; align-items: center; }
+.clusterlane-rp-comp__label strong { display: block; font-size: 14px; color: #0f172a; }
+.clusterlane-rp-comp__label span { font-size: 12px; color: #64748b; }
+.clusterlane-rp-comp__bar { display: flex; gap: 2px; height: 18px; border-radius: 6px; overflow: hidden; background: #f1f5f9; }
+.clusterlane-rp-comp__bar span { flex-basis: 0; min-width: 3px; }
+.clusterlane-rp-comp__legend { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 12.5px; color: #475569; }
+.clusterlane-rp-comp__legend span { display: inline-flex; align-items: center; gap: 5px; }
+.clusterlane-rp-comp__legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
+.clusterlane-rp-comp__legend b { font-weight: 700; color: #0f172a; }
+.clusterlane-rp-comp__legend em { font-style: normal; color: #94a3b8; }
+
+/* Still to do + summary */
+.clusterlane-rp-todo { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 14px; }
+.clusterlane-rp-todo li { display: flex; gap: 12px; align-items: flex-start; margin: 0; }
+.clusterlane-rp-todo__n { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; background: var(--clusterlane-rp-ink); color: #fff; font-size: 12px; font-weight: 700; }
+.clusterlane-rp-todo__title { font-size: 14px; font-weight: 600; color: #0f172a; text-decoration: none; }
+.clusterlane-rp-todo__title:hover { text-decoration: underline; }
+.clusterlane-rp-todo__detail { margin-top: 2px; font-size: 13px; color: #64748b; line-height: 1.5; }
+.clusterlane-rp-todo__detail a { color: #475569; }
+.clusterlane-rp-done { display: flex; gap: 8px; align-items: flex-start; margin: 0; font-size: 14px; color: #15803d; }
+.clusterlane-rp-ai-badge { padding: 2px 8px; border-radius: 999px; background: var(--clusterlane-rp-ink); color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .05em; }
+.clusterlane-rp-summary .clusterlane-rp-card__head { justify-content: flex-start; }
+.clusterlane-rp-summary__body p { margin: 0 0 10px; font-size: 14px; line-height: 1.65; color: #334155; }
+.clusterlane-rp-summary__body p.clusterlane-rp-note { font-size: 12.5px; color: #64748b; }
+.clusterlane-rp-summary__actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 6px; }
+.clusterlane-rp-error { color: #b32d2e; }
+.clusterlane-rp-foot { display: flex; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; margin: 20px 4px 0; font-size: 12px; color: #64748b; }
+.clusterlane-rp-foot a { color: #475569; }
+
+@media (max-width: 1100px) {
+    .clusterlane-rp-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+    .clusterlane-rp-grid--trend { grid-template-columns: minmax(0, 1fr); }
+    .clusterlane-rp-comp { grid-template-columns: 180px minmax(0, 1fr); }
+    .clusterlane-rp-comp__legend { grid-column: 2; }
+}
+@media (max-width: 782px) {
+    .clusterlane-rp-head { padding: 22px 18px; }
+    .clusterlane-rp-head h1 { font-size: 24px !important; }
+    .clusterlane-rp-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+    .clusterlane-rp-kpi__value { font-size: 24px; }
+    .clusterlane-rp-grid--two { grid-template-columns: minmax(0, 1fr); }
+    .clusterlane-rp-card { padding: 18px 16px; }
+    .clusterlane-rp-comp { grid-template-columns: minmax(0, 1fr); gap: 6px; }
+    .clusterlane-rp-comp__legend { grid-column: auto; margin-bottom: 10px; }
+    /* Phones: content-based widths; the role mix and change columns are left out. */
+    .clusterlane-rp-table { table-layout: auto; }
+    .clusterlane-rp-table th, .clusterlane-rp-table td { padding: 10px 8px; }
+    .clusterlane-rp-table .clusterlane-rp-col-mix, .clusterlane-rp-table .clusterlane-rp-col-change { display: none; }
+    .clusterlane-rp-table th:not(:first-child) { width: 1%; white-space: nowrap; }
+}
+
+@media print {
+    #adminmenumain, #wpadminbar, #wpfooter, #screen-meta-links, .notice, .update-nag, .clusterlane-stickybar,
+    .clusterlane-rp-periods, .clusterlane-rp-print, .clusterlane-rp-summary__actions, [data-clusterlane-more], .clusterlane-pro-example-banner,
+    .clusterlane-rp-summary:not(.has-text) { display: none !important; }
+    html.wp-toolbar { padding-top: 0 !important; }
+    #wpcontent, #wpbody-content { margin: 0 !important; padding: 0 !important; }
+    body, #wpwrap { background: #fff !important; }
+    .clusterlane-wrap.clusterlane-rp { max-width: none; margin: 0; }
+    .clusterlane-rp-more { display: table-row-group !important; }
+    .clusterlane-rp-card:not(:has(.clusterlane-rp-table)), .clusterlane-rp-head { break-inside: avoid; }
+    .clusterlane-rp-card { border-color: #cbd5e1; }
+    /* An A4 page is ~700px wide, whatever the screen was: set every layout explicitly. */
+    .clusterlane-rp-head { padding: 22px 24px; }
+    .clusterlane-rp-kpis { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; gap: 8px; }
+    .clusterlane-rp-kpi { padding: 10px 12px; }
+    .clusterlane-rp-kpi__value { font-size: 22px !important; }
+    .clusterlane-rp-kpi__label { font-size: 10px; }
+    .clusterlane-rp-grid--trend { grid-template-columns: minmax(0, 1fr) !important; }
+    .clusterlane-rp-grid--two { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
+    .clusterlane-rp-grid--two:not(:has(.clusterlane-rp-summary.has-text)) { grid-template-columns: minmax(0, 1fr) !important; }
+    .clusterlane-rp-comp { grid-template-columns: 150px minmax(0, 1fr) !important; gap: 6px 16px; }
+    .clusterlane-rp-comp__legend { grid-column: 2 !important; margin-bottom: 8px; }
+    .clusterlane-rp-table { table-layout: auto !important; font-size: 12px; }
+    .clusterlane-rp-table th, .clusterlane-rp-table td { padding: 7px 8px !important; }
+    .clusterlane-rp-table th:not(:first-child) { width: 1% !important; white-space: nowrap; }
+    .clusterlane-rp-table .clusterlane-rp-col-mix, .clusterlane-rp-table .clusterlane-rp-col-change { display: table-cell !important; }
+    .clusterlane-rp-table .clusterlane-rp-mix { width: 110px; }
+    .clusterlane-rp-table tr { break-inside: avoid; }
+    .clusterlane-rp-card { padding: 16px 18px; }
+    .clusterlane-rp a { text-decoration: none; color: inherit; }
+    @page { margin: 12mm; }
+}
+/* Report example (Free): the banner above the header; nothing to print */
+.clusterlane-rp-example-note { margin-top: 16px; }
+.clusterlane-rp-example-note .clusterlane-pro-example-banner { margin: 0; }
+.clusterlane-rp-print:disabled { opacity: .45; cursor: not-allowed; }
+.clusterlane-rp--example .clusterlane-rp-cl-name, .clusterlane-rp--example .clusterlane-rp-todo__title { cursor: default; }
+
+/* Settings → Title Display: live example (0.98.130) */
+.clusterlane-title-preview { margin: 8px 0 0; padding: 7px 10px; max-width: 640px; background: #f6f7fb; border: 1px solid #e3e6ee; border-radius: 6px; font-size: 12.5px; line-height: 1.5; color: #3c434a; overflow-wrap: anywhere; }
+.clusterlane-title-preview__label { color: #646970; }
+.clusterlane-title-preview__from, .clusterlane-title-preview__same { color: #3c434a; }
+.clusterlane-title-preview del { color: #a0a5aa; text-decoration-color: rgba(160,165,170,.7); }
+.clusterlane-title-preview__arrow { color: #8c8f94; }
+.clusterlane-title-preview strong { color: #16223a; }
+.clusterlane-title-preview__note { display: block; margin-top: 2px; color: #646970; font-size: 12px; }
+
+/* Settings → Who can change articles (0.98.132) */
+.clusterlane-access-table { border-collapse: collapse; width: 100%; max-width: 640px; font-size: 13px; }
+.clusterlane-access-table th, .clusterlane-access-table td { padding: 8px 10px; border-bottom: 1px solid #eef0f4; text-align: center; vertical-align: middle; }
+.clusterlane-access-table th:first-child, .clusterlane-access-table td:first-child { text-align: left; }
+.clusterlane-access-table thead th { font-weight: 600; color: #3c434a; font-size: 12px; border-bottom-color: #dcdfe6; }
+.clusterlane-access-table tr.is-fixed td { color: #646970; }
+.clusterlane-access-note { color: #8c8f94; font-size: 12px; }
+@media (max-width: 600px) { .clusterlane-access-table th, .clusterlane-access-table td { padding: 8px 6px; } .clusterlane-access-table thead th { font-size: 11px; } }
+
+/* "Your role can look but not write" (0.98.132) */
+.clusterlane-no-right { margin: 0 0 10px; padding: 8px 12px; background: #f6f7fb; border: 1px solid #e3e6ee; border-left: 3px solid #8c8f94; border-radius: 6px; font-size: 12.5px; line-height: 1.5; color: #3c434a; }
+
+/* Link Card Templates: HTML the design controls can't express (0.98.133) */
+.clusterlane-lt-custom-note { margin: 0 0 10px; padding: 7px 10px; background: #fff8e5; border: 1px solid #f0d99a; border-radius: 6px; font-size: 12.5px; line-height: 1.5; color: #3c434a; }
+.clusterlane-lt-use-controls { padding: 0; border: 0; background: none; color: #2271b1; text-decoration: underline; cursor: pointer; font: inherit; }
+.clusterlane-lt-row.is-custom-html .clusterlane-lt-control-row, .clusterlane-lt-row.is-custom-html .clusterlane-lt-checkbox-label { opacity: .5; }
+
+/* Settings → Link to the pillar (0.98.134) */
+.clusterlane-pillar-preview { margin-top: 10px; max-width: 560px; padding: 12px 14px; background: #fff; border: 1px dashed #dcdfe6; border-radius: 6px; font-size: 14px; line-height: 1.55; color: #1e1e1e; }
+.clusterlane-pillar-preview p, .clusterlane-pillar-preview blockquote { margin: 0; }
+.clusterlane-pillar-status p { margin: 0 0 6px; }
+.clusterlane-auto-pill { flex: none; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: #eef2f7; color: #475569; font-size: 11px; font-weight: 500; white-space: nowrap; }
+.clusterlane-pillar-breakdown { border-collapse: collapse; width: 100%; max-width: 760px; margin: 6px 0 8px; font-size: 13px; }
+.clusterlane-pillar-breakdown th, .clusterlane-pillar-breakdown td { padding: 7px 8px; border-bottom: 1px solid #eef0f4; text-align: left; vertical-align: top; }
+.clusterlane-pillar-breakdown thead th { font-size: 12px; font-weight: 600; color: #3c434a; border-bottom-color: #dcdfe6; }
+.clusterlane-pillar-breakdown .num { white-space: nowrap; }
+.clusterlane-pillar-breakdown a { font-size: 12px; }
+.clusterlane-pillar-warn { color: #b32d2e; font-size: 12px; }
+.clusterlane-pillar-breakdown th:nth-child(2), .clusterlane-pillar-breakdown th:nth-child(3),
+.clusterlane-pillar-breakdown td:nth-child(2), .clusterlane-pillar-breakdown td:nth-child(3) { width: 1%; white-space: nowrap; text-align: center; padding-left: 14px; padding-right: 14px; }
+.clusterlane-pillar-breakdown td:first-child { width: 42%; }
+@media (max-width: 600px) { .clusterlane-pillar-breakdown th:nth-child(2), .clusterlane-pillar-breakdown th:nth-child(3) { white-space: normal; } .clusterlane-pillar-breakdown td:first-child { width: auto; } }
+
+/* Cluster Inspector: what "Make pillar" just did (0.98.137) */
+.clusterlane-ci-pillar-notice { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 12px; padding: 9px 12px; background: #fff7ed; border: 1px solid #fed7aa; border-left: 3px solid #e76c2e; border-radius: 6px; font-size: 13px; line-height: 1.5; color: #3c434a; }
+.clusterlane-ci-pillar-notice svg { flex: none; width: 16px; height: 16px; margin-top: 2px; color: #e76c2e; }
+.clusterlane-ci-pillar-notice span { flex: 1; }
+.clusterlane-ci-pillar-notice__close { flex: none; padding: 0 4px; border: 0; background: none; color: #8c8f94; font-size: 18px; line-height: 1; cursor: pointer; }
+.clusterlane-ci-pillar-notice__close:hover { color: #3c434a; }
+
+/* ── Cluster Inspector → Split this cluster (0.98.138) ─────────────── */
+.clusterlane-improve-count--split { background: #ede9fe; color: #5b21b6; }
+.clusterlane-improve-count--pro { background: #f5f3ff; color: #6d28d9; border: 1px dashed #c4b5fd; }
+.clusterlane-split-head { display: flex; align-items: flex-start; gap: 12px; margin: 0 0 12px; }
+.clusterlane-split-note { flex: 1; display: flex; gap: 8px; margin: 0; font-size: 13.5px; line-height: 1.5; color: #1d2327; }
+.clusterlane-split-note svg { flex: none; width: 16px; height: 16px; margin-top: 2px; color: #16223a; }
+.clusterlane-split-meta { font-size: 12px; color: #8c8f94; white-space: nowrap; margin-top: 2px; }
+/* Regenerate on the light card (0.98.195): a normal small button — the link style was made for the dark SEO panel (light blue, white on hover). */
+.clusterlane-split-regen { flex-shrink: 0; height: 28px; padding: 0 10px; gap: 5px; margin-top: -3px; }
+.clusterlane-split-regen .clusterlane-g { width: 13px; height: 13px; margin: 0; color: #7c3aed; }
+.clusterlane-split-stale { margin: 0 0 12px; padding: 7px 10px; background: #fffbeb; border: 1px solid #f0d99a; border-radius: 6px; font-size: 12.5px; }
+.clusterlane-split-error { color: #b32d2e; }
+.clusterlane-split-top { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 14px; margin-bottom: 14px; }
+.clusterlane-split-graphbox, .clusterlane-split-side > div, .clusterlane-split-card { border: 1px solid #e3e6ee; border-radius: 10px; background: #fff; }
+.clusterlane-split-graphbox { padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; }
+.clusterlane-split-graph-title, .clusterlane-split-ready-title { font-size: 13.5px; font-weight: 600; color: #1d2327; }
+.clusterlane-split-graph { height: 300px; border-radius: 8px; background: #f8fafc; }
+.clusterlane-split-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: #3c434a; }
+.clusterlane-split-legend > span { display: inline-flex; align-items: center; gap: 6px; }
+.clusterlane-split-hint { margin: 0; font-size: 11.5px; color: #8c8f94; }
+.clusterlane-split-dot { display: inline-block; flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--c, #94a3b8); }
+.clusterlane-split-side { display: flex; flex-direction: column; gap: 12px; }
+.clusterlane-split-ready { padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
+.clusterlane-split-ready-row { display: flex; flex-direction: column; gap: 5px; }
+.clusterlane-split-ready-top { display: flex; align-items: center; gap: 8px; font-size: 13px; flex-wrap: wrap; }
+.clusterlane-split-ready-top > span:last-child { margin-left: auto; font-size: 12px; color: #646970; }
+.clusterlane-split-bar { height: 6px; border-radius: 3px; background: #eef0f4; overflow: hidden; }
+.clusterlane-split-bar span { display: block; height: 6px; background: var(--c); }
+.clusterlane-split-verdict { font-size: 12px; color: #50575e; }
+.clusterlane-split-overlap { margin: 0; padding: 10px 12px; border: 1px solid #f0d99a !important; background: #fffbeb !important; border-radius: 10px; font-size: 12.5px; line-height: 1.5; color: #3c434a; }
+.clusterlane-split-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
+.clusterlane-split-card { display: flex; flex-direction: column; overflow: hidden; border-top: 4px solid var(--c); }
+.clusterlane-split-card-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px 4px; }
+.clusterlane-split-name { flex: 1; min-width: 0; font-size: 14.5px; font-weight: 600; color: #1d2327; border: 1px solid transparent; border-radius: 6px; padding: 3px 6px; margin-left: -6px; background: #fff; box-shadow: none; }
+.clusterlane-split-name:hover { border-color: #dcdfe6; }
+.clusterlane-split-name:focus { border-color: #2271b1; outline: none; box-shadow: 0 0 0 1px #2271b1; }
+.clusterlane-split-stayname { flex: 1; font-size: 14.5px; }
+.clusterlane-split-count { font-size: 12px; color: #646970; white-space: nowrap; }
+.clusterlane-split-why { margin: 0; padding: 0 12px 10px; font-size: 12.5px; line-height: 1.45; color: #50575e; }
+.clusterlane-split-rows { border-top: 1px solid #eef0f4; }
+.clusterlane-split-row { display: flex; align-items: center; gap: 8px; padding: 5px 10px 5px 12px; border-bottom: 1px solid #f3f4f7; min-height: 36px; }
+.clusterlane-split-title { flex: 1; min-width: 0; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.clusterlane-split-tag { flex: none; max-width: 38%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; padding: 2px 7px; border-radius: 999px; background: #f1f5f9; color: #475569; }
+.clusterlane-split-tag--hub { background: #fff7ed; color: #9a3412; }
+.clusterlane-split-row .clusterlane-ci-pillar-btn { opacity: 1; flex: none; width: 28px; height: 28px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
+.clusterlane-split-row .clusterlane-ci-pillar-btn:not(.is-chosen) { color: #a7aaad; }
+.clusterlane-split-chips { display: inline-flex; gap: 4px; flex: none; }
+.clusterlane-split-chip { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--c); background: #fff; color: var(--c); font-size: 11px; font-weight: 700; line-height: 1; padding: 0; cursor: pointer; }
+.clusterlane-split-chip.is-on { background: var(--c); color: #fff; }
+.clusterlane-split-chip:focus-visible { outline: 2px solid #2271b1; outline-offset: 1px; }
+.clusterlane-split-empty { margin: 0; padding: 10px 12px; font-size: 12.5px; color: #8c8f94; }
+.clusterlane-split-create { display: flex; align-items: center; gap: 8px; padding: 9px 12px; font-size: 12.5px; color: #3c434a; background: #fafbfc; margin-top: auto; cursor: pointer; }
+.clusterlane-split-create strong { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clusterlane-split-card--stay .clusterlane-split-rows { max-height: 420px; overflow-y: auto; }
+.clusterlane-split-foot { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px; padding-top: 12px; border-top: 1px solid #e3e6ee; }
+.clusterlane-split-foot-text { flex: 1; min-width: 200px; font-size: 13px; color: #50575e; }
+.clusterlane-split-foot-note { font-size: 12.5px; color: #8c8f94; }
+.clusterlane-split-done { flex: 1; display: flex; gap: 8px; align-items: flex-start; margin: 0; padding: 10px 12px; background: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 8px; font-size: 13px; color: #065f46; }
+.clusterlane-split-done svg { flex: none; width: 16px; height: 16px; margin-top: 2px; }
+@media (max-width: 1100px) { .clusterlane-split-top, .clusterlane-split-cards { grid-template-columns: minmax(0, 1fr); } }
+.clusterlane-split-card input.clusterlane-split-name[type="text"] { border: 1px solid #e3e6ee; box-shadow: none; min-height: 32px; }
+.clusterlane-split-card input.clusterlane-split-name[type="text"]:hover { border-color: #c3c4c7; }
+.clusterlane-split-card input.clusterlane-split-name[type="text"]:focus { border-color: #2271b1; box-shadow: 0 0 0 1px #2271b1; }
+@media (max-width: 600px) {
+    .clusterlane-split-head { flex-wrap: wrap; }
+    .clusterlane-split-tag { display: none; }
+    .clusterlane-split-graph { height: 240px; }
+    .clusterlane-split-row { flex-wrap: wrap; row-gap: 6px; padding: 8px 10px; }
+    .clusterlane-split-title { flex-basis: calc(100% - 40px); white-space: normal; }
+    .clusterlane-split-row .clusterlane-ci-pillar-btn { margin-left: 32px; }
+    .clusterlane-split-chips { margin-left: auto; }
+    .clusterlane-split-create { flex-wrap: wrap; }
+    .clusterlane-split-ready-top > span:last-child { margin-left: 18px; width: 100%; }
+}
+
+/* Split: a suggestion that won't be created is greyed out; the pillar idea (0.98.140) */
+.clusterlane-split-card.is-off { border-top-color: #dcdfe6; background: #fafbfc; }
+.clusterlane-split-card.is-off > :not(.clusterlane-split-create) { opacity: .42; filter: grayscale(1); }
+.clusterlane-split-card.is-off .clusterlane-split-create { background: #f0f2f5; }
+.clusterlane-split-idea { display: flex; gap: 10px; align-items: flex-start; flex-wrap: wrap; margin: 0 12px 10px; padding: 9px 11px; background: #fffbeb; border: 1px solid #f0d99a; border-radius: 8px; font-size: 12.5px; line-height: 1.45; color: #3c434a; }
+.clusterlane-split-idea > svg { flex: none; width: 16px; height: 16px; margin-top: 1px; color: #b45309; }
+.clusterlane-split-idea-text { flex: 1; min-width: 200px; }
+.clusterlane-split-idea-text strong { font-weight: 600; color: #1d2327; }
+.clusterlane-split-idea-text small { display: block; margin-top: 3px; font-size: 11.5px; color: #646970; }
+.clusterlane-split-idea-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-left: 26px; }
+
+/* Pillar & split check: the whole cluster's main article (0.98.141) */
+.clusterlane-split-idea--cluster { margin: 0 0 14px; }
+.clusterlane-split-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0 0 14px; padding: 9px 12px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; font-size: 13px; line-height: 1.45; color: #3c434a; }
+.clusterlane-split-pick > svg { flex: none; width: 16px; height: 16px; color: #e76c2e; }
+.clusterlane-split-pick > span { flex: 1; min-width: 200px; }
+.clusterlane-split-pick-now { color: #646970; font-size: 12px; }
+.clusterlane-split-pick--ok { background: #f0fdf4; border-color: #bbf7d0; }
+.clusterlane-split-pick--ok > svg { color: #15803d; }
+.clusterlane-split-onetopic { display: flex; gap: 8px; align-items: flex-start; margin: 0; padding: 10px 12px; background: #f6f7fb; border-radius: 8px; font-size: 13px; color: #3c434a; }
+.clusterlane-split-onetopic svg { flex: none; width: 16px; height: 16px; margin-top: 2px; color: #15803d; }
+
+/* ── Cluster Admin → Review cluster structure (0.98.141) ───────────── */
+.clusterlane-review-card { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin: 18px 0 26px; }
+.clusterlane-review-card-icon { flex: none; width: 40px; height: 40px; border-radius: 10px; background: #eef2f7; color: #16223a; display: flex; align-items: center; justify-content: center; }
+.clusterlane-review-card-icon svg { width: 20px; height: 20px; }
+.clusterlane-review-card-text { flex: 1; min-width: 240px; }
+.clusterlane-review-card-text strong { font-size: 14.5px; color: #1d2327; }
+.clusterlane-review-card-text p { margin: 3px 0 0; font-size: 13px; color: #50575e; }
+.clusterlane-review-dialog { width: min(1080px, calc(100vw - 32px)); max-height: calc(100vh - 48px); padding: 0; border: 0; border-radius: 14px; box-shadow: 0 24px 60px rgba(15, 23, 42, .35); overflow: auto; color: #1d2327; }
+.clusterlane-review-dialog::backdrop { background: rgba(15, 23, 42, .55); }
+.clusterlane-review-head { position: sticky; top: 0; z-index: 2; display: flex; align-items: flex-start; gap: 12px; padding: 16px 22px; background: #16223a; color: #fff; }
+.clusterlane-review-head h2 { margin: 0; font-size: 17px; color: #fff; }
+.clusterlane-review-head p { margin: 3px 0 0; font-size: 12.5px; color: #cbd5e1; }
+.clusterlane-review-head > div { flex: 1; }
+.clusterlane-review-close { flex: none; border: 0; background: none; color: #cbd5e1; font-size: 24px; line-height: 1; cursor: pointer; padding: 0 4px; }
+.clusterlane-review-close:hover { color: #fff; }
+.clusterlane-review-body { padding: 16px 22px 22px; }
+.clusterlane-review-summary { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 12px 14px; background: #f6f7fb; border: 1px solid #e3e6ee; border-radius: 10px; margin-bottom: 12px; }
+.clusterlane-review-summary > div:first-child { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 2px; font-size: 13px; color: #50575e; }
+.clusterlane-review-summary strong { font-size: 14px; color: #1d2327; }
+.clusterlane-review-run { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.clusterlane-review-cost { font-size: 12.5px; color: #50575e; }
+.clusterlane-review-alldone { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: #15803d; }
+.clusterlane-review-alldone svg { width: 16px; height: 16px; }
+.clusterlane-review-progress { display: flex; flex-direction: column; gap: 5px; min-width: 220px; font-size: 12.5px; color: #3c434a; }
+.clusterlane-review-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
+.clusterlane-review-filters button { border: 1px solid #dcdfe6; background: #fff; border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: #3c434a; cursor: pointer; }
+.clusterlane-review-filters button em { font-style: normal; color: #8c8f94; margin-left: 2px; }
+.clusterlane-review-filters button.is-active { background: #16223a; border-color: #16223a; color: #fff; }
+.clusterlane-review-filters button.is-active em { color: #cbd5e1; }
+.clusterlane-review-list { border: 1px solid #e3e6ee; border-radius: 10px; overflow: hidden; }
+.clusterlane-review-row { display: grid; grid-template-columns: minmax(170px, 1.1fr) minmax(0, 3fr) auto; gap: 14px; align-items: center; padding: 11px 14px; border-bottom: 1px solid #eef0f4; background: #fff; }
+.clusterlane-review-row:last-child { border-bottom: 0; }
+.clusterlane-review-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.clusterlane-review-name strong { font-size: 13.5px; }
+.clusterlane-review-name span { font-size: 12px; color: #646970; }
+.clusterlane-review-result { display: flex; flex-direction: column; gap: 5px; min-width: 0; font-size: 12.5px; }
+.clusterlane-review-line { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
+.clusterlane-review-tag { display: inline-flex; align-items: center; gap: 4px; flex: none; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 600; }
+.clusterlane-review-tag svg { width: 12px; height: 12px; }
+.clusterlane-review-tag--split { background: #ede9fe; color: #5b21b6; }
+.clusterlane-review-tag--idea { background: #fef3c7; color: #92400e; }
+.clusterlane-review-tag--pillar { background: #fff7ed; color: #9a3412; }
+.clusterlane-review-tag--ok { background: #dcfce7; color: #166534; }
+.clusterlane-review-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; color: #334155; font-size: 12px; }
+.clusterlane-review-chip em { font-style: normal; color: #8c8f94; }
+.clusterlane-review-chip svg { width: 12px; height: 12px; color: #b45309; }
+.clusterlane-review-text { color: #3c434a; }
+.clusterlane-review-muted { color: #8c8f94; font-size: 12px; display: inline-flex; gap: 4px; align-items: center; }
+.clusterlane-review-muted svg { width: 13px; height: 13px; }
+.clusterlane-review-stale { font-style: italic; }
+.clusterlane-review-actions { display: flex; gap: 6px; justify-content: flex-end; }
+.clusterlane-review-actions a.clusterlane-tm-btn { text-decoration: none; }
+.clusterlane-review-small { margin: 10px 2px 0; font-size: 12px; color: #8c8f94; }
+@media (max-width: 760px) {
+    .clusterlane-review-row { grid-template-columns: minmax(0, 1fr); gap: 8px; }
+    .clusterlane-review-actions { justify-content: flex-start; }
+    .clusterlane-review-body, .clusterlane-review-head { padding-left: 14px; padding-right: 14px; }
+}
+
+/* ── 0.98.147 Phones: tabs, Cluster Admin actions, Dashboard card headers ── */
+@media (max-width: 782px) {
+    /* Settings tabs: two by two instead of a row that scrolls sideways. */
+    .clusterlane-set-tabs { display: grid; grid-template-columns: 1fr 1fr; overflow: visible; }
+    .clusterlane-set-tabs button { justify-content: center; min-width: 0; }
+
+    /* Cluster Admin → Your own clusters: the buttons get their own row
+       under the name, health and orphans. */
+    .clusterlane-ca-table, .clusterlane-ca-table > thead, .clusterlane-ca-table > tbody { display: block; }
+    .clusterlane-ca-table > thead > tr, .clusterlane-ca-table > tbody > tr.clusterlane-ca-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; }
+    .clusterlane-ca-table > thead > tr > th:nth-child(5) { display: none; }
+    .clusterlane-ca-table > thead > tr > th:not(:first-child) { width: auto; }
+    .clusterlane-ca-table > tbody > tr.clusterlane-ca-row > td { border-bottom: 0; }
+    .clusterlane-ca-table > tbody > tr.clusterlane-ca-row > td.clusterlane-ca-actions { grid-column: 1 / -1; display: flex; gap: 6px; padding-top: 0; border-bottom: 1px solid #eef0f4; }
+    .clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn { display: inline-flex; margin: 0; }
+    .clusterlane-ca-table > tbody > tr.clusterlane-ca-editor-row, .clusterlane-ca-table > tbody > tr.clusterlane-ca-editor-row > td { display: block; }
+
+    /* Dashboard card headers: title and arrow on the first row, the
+       controls on a full-width row below. */
+    #clusterlane-sm-card > .clusterlane-site-analysis-header, #clusterlane-dash-graph-card > .clusterlane-site-analysis-header, #clusterlane-site-analysis-card > .clusterlane-site-analysis-header { padding: 18px 18px; gap: 12px; }
+    #clusterlane-sm-card .clusterlane-site-analysis-title, #clusterlane-dash-graph-card .clusterlane-site-analysis-title, #clusterlane-site-analysis-card .clusterlane-site-analysis-title { flex: 1 1 calc(100% - 40px); min-width: 0; order: 0; }
+    #clusterlane-sm-card > .clusterlane-site-analysis-header::after, #clusterlane-dash-graph-card > .clusterlane-site-analysis-header::after, #clusterlane-site-analysis-card > .clusterlane-site-analysis-header::after { order: 1; }
+    #clusterlane-sm-card .clusterlane-dash-graph-controls, #clusterlane-dash-graph-card .clusterlane-dash-graph-controls, #clusterlane-site-analysis-card .clusterlane-site-analysis-actions { order: 2; flex: 1 1 100%; min-width: 0; flex-wrap: wrap; }
+    #clusterlane-dash-graph-select { flex: 1 1 140px; min-width: 0; max-width: 100%; }
+    .clusterlane-dash-graph-ci-btn { flex: 1 1 100%; justify-content: center; text-align: center; box-sizing: border-box; min-height: 36px; padding: 8px 16px; }
+    .clusterlane-site-analysis-meta { white-space: nowrap; }
+}
+
+/* ── 0.98.148 Phones: Cluster Inspector's inline cluster uses the width ──
+   Nine nested frames (page card, table, row, panel, Improve panel, block,
+   list, row) left ~180 px of text on a 390 px screen. Keep the frames,
+   drop most of the padding between them. */
+@media (max-width: 782px) {
+    #clusterlane-ci-list-card > .clusterlane-collapse-body { padding: 12px 8px 14px; }
+    .clusterlane-ci-table > tbody > tr.clusterlane-ci-detail-row > td { padding: 0 0 8px 3px; }
+    .clusterlane-ci-detail-row #clusterlane-ci-detail-panel > .clusterlane-panel-card { border-radius: 10px; }
+    .clusterlane-ci-detail-row #clusterlane-ci-detail-panel > .clusterlane-panel-card > div[style*="padding:20px 24px"] { padding: 10px 6px !important; }
+    .clusterlane-ci-detail-row .clusterlane-improve-panel { border-radius: 10px; margin-bottom: 18px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-panel-header { padding: 14px 12px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-panel-body { padding: 8px 6px 10px; gap: 8px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-block-body { padding: 10px 8px 12px; }
+    .clusterlane-ci-detail-row .clusterlane-tm-group { border-radius: 8px; }
+    .clusterlane-ci-detail-row .clusterlane-tm-row { padding: 10px; gap: 10px; }
+    /* Link articles that need it: Suggest goes under the row, not beside it. */
+    .clusterlane-ci-detail-row .clusterlane-ls2-row-head { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px; }
+    .clusterlane-ci-detail-row .clusterlane-ls2-row-main { min-width: 0; }
+    .clusterlane-ci-detail-row .clusterlane-ls2-row-actions { align-self: flex-start; }
+    .clusterlane-ci-detail-row .clusterlane-ci-al-src { max-width: 100%; }
+    /* Block headers: less padding, the count stays on one line. */
+    .clusterlane-ci-detail-row .clusterlane-improve-block-header { padding: 10px; gap: 8px; align-items: flex-start; }
+    .clusterlane-ci-detail-row .clusterlane-improve-block-label { gap: 8px; min-width: 0; align-items: flex-start; }
+    .clusterlane-ci-detail-row .clusterlane-improve-block-meta { gap: 6px; flex: none; padding-top: 4px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-count { white-space: nowrap; padding: 3px 8px; }
+    .clusterlane-ci-detail-row .clusterlane-improve-summary { padding: 10px 12px; margin-bottom: 10px; }
+}
+
+/* ── 0.98.149 Improve this cluster: numbered steps ──────────────────────
+   The blocks are a to-do list: 1, 2, 3… in a circle instead of the icon.
+   A CSS counter, so hidden blocks (Pillar & split below 6 members, in
+   Free) are skipped and the numbers stay in sequence. */
+#clusterlane-ci-improve .clusterlane-improve-panel-body { counter-reset: clusterlane-step; }
+#clusterlane-ci-improve .clusterlane-improve-block { counter-increment: clusterlane-step; }
+#clusterlane-ci-improve .clusterlane-improve-block-icon { width: 28px; height: 28px; border-radius: 50%; background: #16223a; color: #fff; font-size: 13.5px; font-weight: 700; font-variant-numeric: tabular-nums; box-shadow: 0 1px 2px rgba(15,23,42,.25); }
+#clusterlane-ci-improve .clusterlane-improve-block-icon > * { display: none; }
+#clusterlane-ci-improve .clusterlane-improve-block-icon::before { content: counter(clusterlane-step); line-height: 1; }
+#clusterlane-ci-improve .clusterlane-improve-block-header:hover .clusterlane-improve-block-icon { background: #1e3a5f; }
+
+/* ── 0.98.150 Phones: the expanded cluster graph is a full-screen view ──
+   The desktop modal (graph + 260 px side panel, header on one row) left the
+   graph ~100 px wide on a 390 px screen. On phones: full screen, header as
+   title + × / info / legend, the graph on top and the article details in a
+   panel below that grows when a node is clicked. */
+.clusterlane-ci-graph-modal-header .clusterlane-graph-legend { margin-top: 0; padding-top: 0; border-top: 0; }
+@media (max-width: 782px) {
+    .clusterlane-ci-graph-modal-overlay { padding: 0; align-items: stretch; }
+    .clusterlane-ci-graph-modal-inner { height: 100%; height: 100dvh; max-height: none; border-radius: 0; box-shadow: none; }
+    .clusterlane-ci-graph-modal-header {
+        display: grid;
+        grid-template-columns: minmax(0, 1fr) auto;
+        grid-template-areas: "title close" "info info" "legend legend";
+        align-items: center;
+        gap: 8px 12px;
+        padding: 12px 14px;
+    }
+    .clusterlane-ci-graph-modal-header > div { display: contents !important; }
+    .clusterlane-ci-graph-modal-title { grid-area: title; font-size: 1.05rem; line-height: 1.3; overflow-wrap: anywhere; }
+    #clusterlane-ci-graph-modal-close { grid-area: close; }
+    #clusterlane-ci-graph-modal-infobar { grid-area: info; }
+    #clusterlane-ci-graph-modal-legend { grid-area: legend; }
+    .clusterlane-ci-graph-modal-header .clusterlane-graph-infobar { gap: 4px 12px; font-size: 11.5px; }
+    .clusterlane-ci-graph-modal-header .clusterlane-graph-infobar > span { padding: 0 !important; white-space: nowrap; }
+    .clusterlane-ci-graph-modal-header .clusterlane-graph-infobar > span::after { display: none; }
+    .clusterlane-ci-graph-modal-header .clusterlane-graph-infobar .clusterlane-graph-infobar-arch { padding: 1px 9px !important; margin: 0; }
+    .clusterlane-ci-graph-modal-body { flex-direction: column; }
+    .clusterlane-ci-graph-modal-canvas { flex: 1 1 auto; }
+    .clusterlane-ci-graph-modal-right {
+        width: auto;
+        max-height: 42%;
+        flex: 0 0 auto;
+        padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
+        border-left: 0;
+        border-top: 1px solid #e2e8f0;
+    }
+    .clusterlane-ci-graph-modal-right > p { margin: 0 !important; }
+}
+
+/* ── 0.98.151 Phones: the dock sticks to the top once the admin bar is gone ──
+   At 600 px and below WordPress makes #wpadminbar position:absolute, so it
+   scrolls away with the page; top:46px left an empty strip above the dock.
+   601–782 px keeps the fixed 46 px admin bar and the 46 px offset. */
+@media screen and (max-width: 600px) {
+    .clusterlane-stickybar, #clusterlane-sticky-bar { top: 0; }
+}
+
+/* ── 0.98.152 Improve this article: numbered steps ──────────────────────
+   Same idea as Improve this cluster (0.98.149): the dot becomes a navy
+   circle with 1, 2, 3… (a CSS counter). Severity stays in the left border.
+   The title stays beside the circle when it wraps. */
+.clusterlane-ai-improve__list { counter-reset: clusterlane-imp; }
+.clusterlane-ai-improve__list > .clusterlane-ai-improve__item { counter-increment: clusterlane-imp; }
+.clusterlane-ai-improve__item-head { flex-wrap: nowrap; align-items: flex-start; gap: 10px; }
+.clusterlane-ai-improve__item-head strong { flex: 0 1 auto; min-width: 0; line-height: 1.4; padding-top: 3px; }
+.clusterlane-ai-improve__first { flex: none; margin-top: 4px; }
+.clusterlane-ai-improve__node {
+    width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center;
+    background: #16223a; color: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.25);
+    font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums;
+}
+.clusterlane-ai-improve__node::before { content: counter(clusterlane-imp); line-height: 1; }
+.clusterlane-ai-improve__why, .clusterlane-ai-improve__lead, .clusterlane-ai-improve__item .clusterlane-tm-group, .clusterlane-ai-improve__more, .clusterlane-ai-improve__actions { margin-left: 36px; }
+.clusterlane-ai-improve__tips { margin-left: 54px; }
+
+/* Phones: the text of an empty Improve block on its own row, the button under it. */
+@media (max-width: 782px) {
+    .clusterlane-ai-improve__why, .clusterlane-ai-improve__lead, .clusterlane-ai-improve__item .clusterlane-tm-group, .clusterlane-ai-improve__more, .clusterlane-ai-improve__actions { margin-left: 0; }
+    .clusterlane-ai-improve__tips { margin-left: 18px; }
+    .clusterlane-improve-cta { flex-direction: column; align-items: stretch; gap: 10px; }
+    .clusterlane-improve-cta > button, .clusterlane-improve-cta > a { align-self: flex-start; }
+}
+
+/* ── 0.98.154 SEO Titles ─────────────────────────────────────────────────
+   Every article's SEO title + meta description: a list of cards, each with
+   both fields, the AI's suggestion under each field (Use / Keep current),
+   a light search preview and Apply. Chips and counters reuse the Article
+   Inspector classes, recoloured for a white card. */
+.clusterlane-browse__seo-link { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600; color: #1d4ed8; text-decoration: none; }
+.clusterlane-browse__seo-link .clusterlane-g { width: 14px; height: 14px; }
+.clusterlane-browse__seo-link:hover { text-decoration: underline; }
+.clusterlane-seot.is-loading .clusterlane-seot__list { opacity: .55; transition: opacity .15s; }
+.clusterlane-seot__note { color: #92400e !important; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 8px 12px; margin: 0 0 12px !important; }
+.clusterlane-seot__dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
+.clusterlane-seot__dot--bad { background: #ef4444; }
+.clusterlane-seot__dot--warn { background: #f59e0b; }
+
+.clusterlane-seot__bulk { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin: 14px 0 12px; padding: 10px 14px; border: 1px solid #e2e8f0; border-radius: 10px; background: #f8fafc; }
+.clusterlane-seot__all { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: #334155; cursor: pointer; }
+.clusterlane-seot__bulk-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
+.clusterlane-seot__bulk-actions .clusterlane-tm-btn { display: inline-flex; align-items: center; gap: 6px; }
+.clusterlane-seot__bulk-actions [hidden] { display: none !important; }
+.clusterlane-seot__run[hidden] { display: none !important; }
+.clusterlane-seot__bulk-actions .clusterlane-g { width: 14px; height: 14px; }
+.clusterlane-seot__bulk-actions .clusterlane-pro-badge { margin-left: 2px; }
+.clusterlane-seot__run { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-top: 10px; border-top: 1px solid #e2e8f0; font-size: 13px; color: #334155; }
+.clusterlane-seot__run .clusterlane-g { width: 14px; height: 14px; margin-right: 5px; vertical-align: -2px; color: #7c3aed; }
+.clusterlane-seot__example { margin-bottom: 18px; }
+
+.clusterlane-seot__list { display: grid; gap: 12px; }
+.clusterlane-seot__row { border: 1px solid #e2e8f0; border-left: 3px solid #e2e8f0; border-radius: 12px; background: #fff; padding: 12px 14px; }
+.clusterlane-seot__row.is-selected { background: #f8fbff; border-color: #bfdbfe; }
+.clusterlane-seot__row.is-changed { border-left-color: #2563eb; }
+.clusterlane-seot__row.is-applied { border-left-color: #16a34a; }
+/* Compact view (0.98.187): one line per article, Edit opens the card */
+.clusterlane-seot--compact #clusterlane-seot-rows { gap: 0; }
+.clusterlane-seot__row--compact {
+    display: grid; grid-template-columns: 18px 32px minmax(180px, 1.15fr) minmax(0, 1fr) minmax(0, 1.6fr) auto;
+    align-items: center; gap: 10px 14px; padding: 9px 14px; border-radius: 0; border-width: 0 0 1px; border-left: 0;
+}
+.clusterlane-seot--compact #clusterlane-seot-rows > .clusterlane-seot__row:first-child { border-top-left-radius: 12px; border-top-right-radius: 12px; }
+.clusterlane-seot--compact #clusterlane-seot-rows > .clusterlane-seot__row--compact { border: 1px solid #e2e8f0; border-top-width: 0; }
+.clusterlane-seot--compact #clusterlane-seot-rows > .clusterlane-seot__row--compact:first-child { border-top-width: 1px; }
+.clusterlane-seot--compact #clusterlane-seot-rows > .clusterlane-seot__row:last-child { border-bottom-left-radius: 12px; border-bottom-right-radius: 12px; }
+.clusterlane-seot--compact #clusterlane-seot-rows > .clusterlane-seot__row:not(.clusterlane-seot__row--compact) { margin: 8px 0; }
+.clusterlane-seot__row--compact:hover { background: #f8fafc; }
+.clusterlane-seot__row--compact .clusterlane-seot__name { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
+.clusterlane-seot__row--compact .clusterlane-seot__name a { font-weight: 600; color: #0f172a; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clusterlane-seot__row--compact .clusterlane-seot__name a:hover { color: #1d4ed8; text-decoration: underline; }
+.clusterlane-seot__cchips { display: flex; flex-wrap: wrap; gap: 4px; }
+.clusterlane-seot__cchips .clusterlane-serp__chip { font-size: 10.5px; padding: 1px 6px; }
+.clusterlane-seot__cval { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
+.clusterlane-seot__clbl { font-size: 10px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #94a3b8; }
+.clusterlane-seot__ctext { font-size: 12.5px; color: #334155; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.clusterlane-seot__cval:not(.clusterlane-seot__cval--desc) .clusterlane-seot__ctext { -webkit-line-clamp: 1; }
+.clusterlane-seot__cnone, .clusterlane-seot__cnone em { color: #94a3b8; font-style: italic; }
+.clusterlane-seot__edit { gap: 5px; }
+.clusterlane-seot__edit .clusterlane-g { width: 13px; height: 13px; margin: 0; }
+.clusterlane-seot__collapse { margin-left: auto; }
+.clusterlane-seot__row--compact.is-changed { box-shadow: inset 3px 0 0 #2563eb; }
+.clusterlane-seot__cstate { background: #eff6ff; color: #1d4ed8; }
+.clusterlane-seot__cchips .clusterlane-g { width: 11px; height: 11px; margin: 0 3px 0 0; vertical-align: -1px; }
+.clusterlane-seot__ai-one { display: inline-flex; flex-wrap: wrap; gap: 6px; margin-right: 6px; }
+.clusterlane-seot__ai-one .clusterlane-tm-btn { gap: 5px; height: 28px; padding: 0 10px; }
+.clusterlane-seot__ai-one .clusterlane-g { width: 13px; height: 13px; margin: 0; color: #7c3aed; }
+.clusterlane-seot__ai-one .clusterlane-pro-badge { margin-left: 2px; }
+.clusterlane-seot__view { margin-left: auto; }
+.clusterlane-seot__view + .clusterlane-csv-btn { margin-left: 0; }
+@media (max-width: 960px) {
+    .clusterlane-seot__row--compact { grid-template-columns: 18px 32px minmax(0, 1fr) auto; }
+    .clusterlane-seot__row--compact .clusterlane-seot__cval { grid-column: 3 / -1; }
+    .clusterlane-seot__row--compact .clusterlane-seot__edit { grid-column: 4; grid-row: 1; }
+}
+.clusterlane-seot__head { display: flex; align-items: center; gap: 10px; }
+.clusterlane-seot__sel { margin: 0 !important; flex: none; }
+.clusterlane-seot__name { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; }
+.clusterlane-seot__name a, .clusterlane-seot__name-text { font-weight: 600; font-size: 14px; color: #0f172a; text-decoration: none; overflow-wrap: anywhere; }
+.clusterlane-seot__name a:hover { color: #1d4ed8; text-decoration: underline; }
+.clusterlane-seot__chips-row { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
+.clusterlane-seot .clusterlane-serp__chip--ok { background: #dcfce7; color: #166534; }
+.clusterlane-seot .clusterlane-serp__chip--warn { background: #fef3c7; color: #92400e; }
+.clusterlane-seot .clusterlane-serp__chip--bad { background: #fee2e2; color: #991b1b; }
+
+.clusterlane-seot__fields { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 12px; margin-top: 10px; }
+.clusterlane-seot__field { min-width: 0; }
+.clusterlane-seot .clusterlane-seo-ai__lbl { color: #64748b; }
+.clusterlane-seot .clusterlane-seo-ai__count--ok { background: #dcfce7; color: #166534; }
+.clusterlane-seot .clusterlane-seo-ai__count--warn { background: #fef3c7; color: #92400e; }
+.clusterlane-seot .clusterlane-seo-ai__count--none { background: #f1f5f9; color: #64748b; }
+.clusterlane-seot__in { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1 !important; border-radius: 8px !important; padding: 6px 10px !important; font-size: 13.5px; line-height: 1.45; color: #0f172a; background: #fff; }
+textarea.clusterlane-seot__in { resize: vertical; min-height: 58px; }
+.clusterlane-seot__in:focus { border-color: #3b82f6 !important; box-shadow: 0 0 0 2px rgba(59,130,246,.2) !important; outline: none; }
+.clusterlane-seot__in::placeholder { color: #94a3b8; }
+.clusterlane-seot__from { margin: 4px 0 0; font-size: 12px; color: #94a3b8; font-style: italic; }
+
+.clusterlane-seot__ai { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 4px 8px; margin-top: 6px; padding: 7px 10px; border-radius: 8px; background: #f5f3ff; border: 1px dashed #c4b5fd; font-size: 12.5px; line-height: 1.45; color: #312e81; }
+.clusterlane-seot__ai.is-used { background: #eef2ff; border-style: solid; border-color: #a5b4fc; }
+.clusterlane-seot__ai-tag { display: inline-flex; align-items: center; gap: 3px; font-size: 10.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #6d28d9; padding-top: 1px; }
+.clusterlane-seot__ai-tag .clusterlane-g { width: 12px; height: 12px; }
+.clusterlane-seot__ai-text { flex: 1 1 200px; min-width: 0; }
+.clusterlane-seot__ai-text--used { color: #4338ca; font-style: italic; }
+.clusterlane-seot__ai-acts { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; white-space: nowrap; }
+.clusterlane-seot__ai-nav { display: inline-flex; align-items: center; gap: 2px; color: #64748b; font-variant-numeric: tabular-nums; }
+.clusterlane-seot__ai-nav button { border: 0; background: none; cursor: pointer; color: #4c1d95; font-size: 15px; line-height: 1; padding: 0 4px; }
+.clusterlane-seot__use { border: 1px solid #7c3aed; background: #7c3aed; color: #fff; border-radius: 6px; padding: 2px 10px; font-size: 12px; font-weight: 600; cursor: pointer; }
+.clusterlane-seot__use:hover { background: #6d28d9; }
+.clusterlane-seot__ai-on { display: inline-flex; align-items: center; gap: 3px; font-weight: 600; color: #3730a3; }
+.clusterlane-seot__ai-on .clusterlane-g { width: 13px; height: 13px; }
+
+.clusterlane-seot__foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 10px; }
+.clusterlane-seot__link { display: inline-flex; align-items: center; gap: 4px; border: 0; background: none; padding: 0; cursor: pointer; color: #1d4ed8; font-size: 12.5px; font-weight: 600; text-decoration: none; }
+.clusterlane-seot__link .clusterlane-g { width: 13px; height: 13px; }
+.clusterlane-seot__link:hover { text-decoration: underline; }
+.clusterlane-seot__foot-state { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12.5px; }
+.clusterlane-seot__state { color: #64748b; display: inline-flex; align-items: center; gap: 4px; }
+.clusterlane-seot__state .clusterlane-g { width: 13px; height: 13px; }
+.clusterlane-seot__state--ok, .clusterlane-seot__state--ok .clusterlane-g { color: #15803d; }
+.clusterlane-seot__state--bad { color: #b91c1c; }
+.clusterlane-seot__state--changed { color: #1d4ed8; font-weight: 600; }
+.clusterlane-seot__foot-btns { margin-left: auto; display: flex; gap: 8px; }
+
+/* Light search preview (Google-like) on the white card. */
+.clusterlane-seot .clusterlane-seot__serp { max-width: 600px; background: #fff; border-color: #e2e8f0; -webkit-backdrop-filter: none; backdrop-filter: none; }
+.clusterlane-seot .clusterlane-serp__fav { background: #f1f3f4; border-color: #e2e8f0; color: #202124; }
+.clusterlane-seot .clusterlane-serp__name { color: #202124; }
+.clusterlane-seot .clusterlane-serp__url { color: #4d5156; }
+.clusterlane-seot .clusterlane-serp__title { color: #1a0dab; }
+.clusterlane-seot .clusterlane-serp__desc { color: #4d5156; }
+.clusterlane-seot .clusterlane-serp__desc--none { color: #70757a; }
+
+@media (max-width: 782px) {
+    .clusterlane-browse__seo-link { margin-left: 0; }
+    .clusterlane-seot__bulk { padding: 10px; }
+    .clusterlane-seot__bulk-actions { margin-left: 0; width: 100%; }
+    .clusterlane-seot__bulk-actions .clusterlane-tm-btn { flex: 1 1 auto; justify-content: center; }
+    .clusterlane-seot__row { padding: 10px; }
+    .clusterlane-seot__head { flex-wrap: wrap; }
+    .clusterlane-seot__name { flex-basis: calc(100% - 90px); }
+    .clusterlane-seot__chips-row { flex-basis: 100%; justify-content: flex-start; }
+    .clusterlane-seot__fields { grid-template-columns: minmax(0, 1fr); gap: 10px; }
+    .clusterlane-seot__ai-acts { margin-left: 0; }
+    .clusterlane-seot__foot-btns { margin-left: 0; width: 100%; }
+    .clusterlane-seot__foot-btns .clusterlane-tm-btn { flex: 1 1 auto; }
+}
+
+/* ── 0.98.155 SEO Titles: AI review (Check with AI) and duplicates ─────── */
+.clusterlane-seot__dot--ai { background: #7c3aed; }
+.clusterlane-seot__aichip.is-stale { opacity: .55; }
+.clusterlane-seot__review { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin: 10px 0 0; padding: 7px 10px; border-radius: 8px; background: #faf5ff; border: 1px solid #e9d5ff; font-size: 12.5px; line-height: 1.45; color: #334155; }
+.clusterlane-seot__review.is-stale { background: #f8fafc; border-color: #e2e8f0; color: #64748b; }
+.clusterlane-seot__review .clusterlane-seot__ai-tag { align-self: center; }
+.clusterlane-seot__rv-part b { font-weight: 700; }
+.clusterlane-seot__rv-part--bad b { color: #b91c1c; }
+.clusterlane-seot__rv-part--warn b { color: #b45309; }
+.clusterlane-seot__rv-part--ok b { color: #15803d; }
+.clusterlane-seot__rv-stale { font-style: italic; color: #94a3b8; }
+@media (max-width: 782px) {
+    .clusterlane-seot__review { flex-direction: column; gap: 3px; }
+    .clusterlane-seot__review .clusterlane-seot__ai-tag { align-self: flex-start; }
+}
+
+/* 0.98.158 AI Link Suggestions: the chosen place was inside a box — the card went to the end. */
+.clusterlane-ls-placement-note { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; padding: 7px 10px; border-radius: 8px; background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 12.5px; }
+.clusterlane-ls-placement-note[hidden] { display: none; }
+.clusterlane-ls-placement-note .clusterlane-g { width: 14px; height: 14px; flex: none; }
+
+/* 0.98.159 Pillar & split check: one way of dividing per suggestion, a switch between two. */
+.clusterlane-split-by { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 4px 0 12px; }
+.clusterlane-split-by-label { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #64748b; }
+.clusterlane-split-by-btn { border: 1px solid #cbd5e1; background: #fff; color: #334155; border-radius: 999px; padding: 4px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer; }
+.clusterlane-split-by-btn em { font-style: normal; color: #94a3b8; font-weight: 600; margin-left: 2px; }
+.clusterlane-split-by-btn.is-active { background: #16223a; border-color: #16223a; color: #fff; }
+.clusterlane-split-by-btn.is-active em { color: #cbd5e1; }
+.clusterlane-split-by-hint { font-size: 12px; color: #94a3b8; }
+
+/* 0.98.160 — cluster sections in Cluster Inspector */
+.clusterlane-ci-group{display:flex;align-items:center;gap:8px}
+.clusterlane-ci-group[hidden]{display:none!important}
+.clusterlane-ci-table tr.clusterlane-ci-section-row td{padding:0;background:#f1f5f9;border-top:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0}
+.clusterlane-ci-section-toggle{display:flex;align-items:center;gap:10px;width:100%;padding:10px 14px;border:0;background:none;cursor:pointer;font:inherit;text-align:left;color:#16223a}
+.clusterlane-ci-section-toggle:hover{background:#e8edf3}
+.clusterlane-ci-section-toggle:focus-visible{outline:2px solid #2563eb;outline-offset:-2px}
+.clusterlane-ci-section-caret{width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid #64748b;transition:transform .15s}
+.clusterlane-ci-section-row.is-folded .clusterlane-ci-section-caret{transform:rotate(-90deg)}
+.clusterlane-ci-section-name{font-weight:700;font-size:13px;letter-spacing:.02em;text-transform:uppercase}
+.clusterlane-ci-section-meta{font-size:12px;color:#64748b;flex:1}
+.clusterlane-ci-section-toggle .clusterlane-ci-health-cell{margin-left:auto;font-weight:700;font-size:13px}
+.clusterlane-ci-section-pick{display:inline-flex;align-items:center;gap:6px}
+.clusterlane-ci-section-pick select{font-size:12px;padding:1px 22px 1px 6px;min-height:26px;border-radius:6px}
+@media (max-width:600px){.clusterlane-ci-section-meta{display:none}}
+.clusterlane-graph-infobar .clusterlane-ci-section-pick select{height:24px;min-height:24px;line-height:1;padding:0 22px 0 8px;background-color:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.25);background-position:right 4px center;background-size:14px}
+.clusterlane-graph-infobar .clusterlane-ci-section-pick select option{color:#16223a;background:#fff}
+
+/* 0.98.161 — your own cluster groups, drag to a section */
+.clusterlane-ci-group .clusterlane-seg[hidden]{display:none!important}
+.clusterlane-ci-new-group{padding:5px 10px;font-size:12px}
+.clusterlane-ci-section-bar{display:flex;align-items:center}
+.clusterlane-ci-section-bar .clusterlane-ci-section-toggle{flex:1;min-width:0}
+.clusterlane-ci-section-row.is-mine td{background:#eef2ff}
+.clusterlane-ci-section-row.is-mine .clusterlane-ci-section-name{color:#3730a3}
+.clusterlane-ci-section-acts{display:flex;gap:2px;padding-right:10px}
+.clusterlane-ci-section-acts button{width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:none;color:#475569;cursor:pointer;font-size:14px;line-height:1}
+.clusterlane-ci-section-acts button:hover:not(:disabled){background:#dbe3f5;color:#16223a}
+.clusterlane-ci-section-acts button:disabled{opacity:.3;cursor:default}
+.clusterlane-ci-section-acts button svg{width:13px;height:13px}
+.clusterlane-ci-table tr.clusterlane-ci-cluster-row[draggable="true"]{cursor:grab}
+.clusterlane-ci-table tr.clusterlane-ci-cluster-row.is-dragging{opacity:.45}
+#clusterlane-ci-cluster-list.is-dragging tr.clusterlane-ci-section-row td{outline:1px dashed #94a3b8;outline-offset:-3px}
+#clusterlane-ci-cluster-list tr.clusterlane-ci-section-row.is-drop td{background:#dbeafe;outline:2px solid #2563eb;outline-offset:-3px}
+/* 0.98.162 — group buttons right after the section text, health lined up with the cluster rows */
+.clusterlane-ci-section-bar{cursor:pointer}
+.clusterlane-ci-section-bar:hover{background:#e8edf3}
+.clusterlane-ci-section-row.is-mine .clusterlane-ci-section-bar:hover{background:#e0e7ff}
+.clusterlane-ci-section-bar .clusterlane-ci-section-toggle{flex:0 1 auto;width:auto}
+.clusterlane-ci-section-bar .clusterlane-ci-section-toggle:hover{background:none}
+.clusterlane-ci-section-meta{flex:none}
+.clusterlane-ci-section-acts{padding-right:0}
+.clusterlane-ci-section-health{margin-left:auto;padding:0 14px 0 12px;font-weight:700;font-size:13px;white-space:nowrap}
+.clusterlane-ci-section-health{padding-right:12px}
+@media (max-width:600px){.clusterlane-ci-section-health{padding-right:8px}}
+/* 0.98.163 — Cluster Inspector toolbar in two rows: view + grouping, then search + filters */
+.clusterlane-toolbar--rows{flex-direction:column;align-items:stretch;gap:8px}
+.clusterlane-toolbar-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.clusterlane-toolbar--rows .clusterlane-toolbar-row + .clusterlane-toolbar-row{padding-top:8px;border-top:1px solid #e2e8f0}
+.clusterlane-toolbar--rows.is-map .clusterlane-ci-group{display:none!important}
+
+/* 0.98.166 — Export CSV buttons */
+.clusterlane-csv-btn{display:inline-flex;align-items:center;gap:6px;margin-left:auto;padding:5px 10px;border:1px solid #e2e8f0;border-radius:7px;background:#fff;color:#334155;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}
+.clusterlane-csv-btn:hover{border-color:#94a3b8;color:#16223a}
+.clusterlane-csv-btn:disabled{opacity:.5;cursor:wait}
+.clusterlane-csv-btn svg{width:14px;height:14px}
+.clusterlane-browse__head .clusterlane-browse__seo-link + .clusterlane-csv-btn{margin-left:8px}
+
+/* 0.98.168 — language tag on multilingual sites */
+.clusterlane-lang-tag{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid #cbd5e1;border-radius:4px;font-size:10px;font-weight:700;line-height:16px;letter-spacing:.04em;text-transform:uppercase;color:#475569;background:#f8fafc;vertical-align:middle}
+
+#clusterlane-ai-browse select#clusterlane-ab-lang, #clusterlane-seot select#clusterlane-seot-lang { max-width: 160px; }
+
+/* 0.98.169 — big clusters: the graph shows part of them */
+.clusterlane-graph-trunc{margin:0 0 8px;padding:7px 10px;border-radius:7px;background:#fef9c3;color:#713f12;font-size:12px;line-height:1.45}
+.clusterlane-type-tag{border-color:#c4b5fd;color:#5b21b6;background:#f5f3ff;text-transform:none;letter-spacing:0}
+.clusterlane-type-filter__txt { margin-left: 4px; font-size: 12px; }
+
+/* Dashboard: post types with almost no links in their text (0.98.177) */
+.clusterlane-low-links { display: flex; gap: 10px; align-items: flex-start; margin: 12px 0 16px; padding: 12px 16px; border: 1px solid #fcd34d; border-left-width: 4px; background: #fffbeb; border-radius: 8px; color: #78350f; font-size: 13px; line-height: 1.55; }
+.clusterlane-low-links p { margin: 0; }
+.clusterlane-low-links a { font-weight: 600; white-space: nowrap; }
+
+/* Sticky bar: published pages being read in the background (0.98.178) */
+.clusterlane-stickybar__pages { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 8px; border: 1px solid rgba(255,255,255,.12); color: var(--sb-text); font-size: 12px; white-space: nowrap; cursor: default;
+    background: linear-gradient(90deg, rgba(59,130,246,.35) var(--clusterlane-pages-pct, 0%), transparent var(--clusterlane-pages-pct, 0%)); }
+.clusterlane-stickybar__pages[hidden] { display: none; }
+.clusterlane-stickybar__pages svg { width: 13px; height: 13px; animation: clusterlane-pages-spin 1.6s linear infinite; }
+@keyframes clusterlane-pages-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .clusterlane-stickybar__pages svg { animation: none; } }
+
+/* Cluster Admin: pages as clusters (0.98.180) */
+.clusterlane-ca-hub .clusterlane-ca-term-name { display: flex; flex-direction: column; gap: 2px; }
+.clusterlane-ca-hub-paths { font-weight: 400; font-size: 12px; color: #64748b; }
+.clusterlane-ca-status--on { color: #047857; font-weight: 600; font-size: 12px; }
+.clusterlane-ca-hub-list { margin: 0 0 6px 28px; border-left: 2px solid #e2e8f0; padding-left: 10px; }
+.clusterlane-ca-hub-item { cursor: default; }
+
+/* Text Match: apply all (0.98.184) */
+.clusterlane-tm-bulk { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; }
+.clusterlane-tm-bulk-progress { font-size: 12.5px; color: #475569; }
+.clusterlane-tm-row--failed { border-left: 3px solid #f59e0b; }
+.clusterlane-tm-error { margin: 4px 0 0; font-size: 12px; color: #b45309; display: flex; gap: 5px; align-items: center; }
+.clusterlane-tm-error svg { width: 13px; height: 13px; }
+
+/* Cluster info bar: structure figures fold away (0.98.187) */
+.clusterlane-gib-tech { display: inline-flex; align-items: center; gap: 14px; }
+.clusterlane-gib-tech > summary { cursor: pointer; list-style: none; font-size: 12px; color: inherit; opacity: .75; text-decoration: underline dotted; text-underline-offset: 3px; }
+.clusterlane-gib-tech > summary::-webkit-details-marker { display: none; }
+.clusterlane-gib-tech > summary::after { content: " ▸"; }
+.clusterlane-gib-tech[open] > summary::after { content: " ▾"; }
+.clusterlane-gib-tech > summary:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; border-radius: 4px; }
+.clusterlane-ci-detail-back { margin: 16px 0 0; text-align: center; }
+.clusterlane-ci-detail-back .clusterlane-g { transform: rotate(180deg); margin-right: 6px; }
+.clusterlane-ls2-regen .clusterlane-tm-linkbtn .clusterlane-g { width: 13px; height: 13px; margin: 0 4px 0 0; vertical-align: -2px; color: #7c3aed; }
+
+/* =========================================================
+   CLUSTER QUICK VIEW (0.98.200) — .clusterlane-cq
+   Eye button in Report's cluster table and the Cluster Inspector list.
+========================================================= */
+.clusterlane-rp-table th.clusterlane-rp-col-quick { width: 44px; }
+.clusterlane-rp-table td.clusterlane-rp-col-quick { padding-left: 0; padding-right: 10px; text-align: right; }
+.clusterlane-rp-quick, .clusterlane-ci-quick { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: none; color: #64748b; cursor: pointer; }
+.clusterlane-rp-quick:hover, .clusterlane-rp-quick:focus-visible, .clusterlane-ci-quick:hover, .clusterlane-ci-quick:focus-visible { color: #2563eb; background: #eff6ff; border-color: #bfdbfe; }
+.clusterlane-rp-quick:focus-visible, .clusterlane-ci-quick:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
+.clusterlane-rp-quick .clusterlane-g, .clusterlane-ci-quick .clusterlane-g { width: 17px; height: 17px; margin: 0; }
+.clusterlane-rp-table tbody tr:hover .clusterlane-rp-quick { color: #2563eb; }
+.clusterlane-ci-table .clusterlane-browse__open { max-width: calc(100% - 64px); }
+.clusterlane-ci-quick { width: 26px; height: 26px; margin: -4px 0 -4px 6px; vertical-align: top; opacity: 0; }
+.clusterlane-ci-cluster-row:hover .clusterlane-ci-quick, .clusterlane-ci-cluster-row--active .clusterlane-ci-quick, .clusterlane-ci-quick:focus-visible { opacity: 1; }
+@media (hover: none) { .clusterlane-ci-quick { opacity: 1; } }
+@media print { .clusterlane-rp-col-quick { display: none !important; } }
+
+html.clusterlane-cq-lock, html.clusterlane-cq-lock body { overflow: hidden; }
+.clusterlane-cq-overlay { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; background: rgba(15,23,42,.62); }
+.clusterlane-cq-overlay[hidden] { display: none; }
+.clusterlane-cq { width: 100%; max-width: 860px; height: min(88vh, 900px); display: flex; flex-direction: column; overflow: hidden; background: #f8fafc; border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,.32); outline: none; font-size: 13px; color: #1e293b; }
+.clusterlane-cq__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 16px 18px 16px 22px; background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%); color: #fff; flex-shrink: 0; }
+.clusterlane-cq__headtext { min-width: 0; }
+.clusterlane-cq__kicker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 12px; color: rgba(255,255,255,.72); min-height: 18px; }
+.clusterlane-cq__kicker .clusterlane-g { width: 14px; height: 14px; margin: 0; }
+.clusterlane-cq__kicker .clusterlane-tip-icon { background: rgba(255,255,255,.18); color: #fff; }
+.clusterlane-cq__dot { opacity: .5; }
+.clusterlane-cq__title { margin: 4px 0 0; font-size: 20px; line-height: 1.25; font-weight: 700; color: #fff; overflow-wrap: anywhere; }
+.clusterlane-cq__tools { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
+.clusterlane-cq__pos { font-size: 12px; color: rgba(255,255,255,.6); margin-right: 4px; font-variant-numeric: tabular-nums; }
+.clusterlane-cq__navbtn, .clusterlane-cq__close { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: 1px solid rgba(255,255,255,.18); border-radius: 8px; background: rgba(255,255,255,.08); color: #fff; font-size: 20px; line-height: 1; cursor: pointer; }
+.clusterlane-cq__navbtn[hidden] { display: none; }
+.clusterlane-cq__navbtn:hover, .clusterlane-cq__close:hover, .clusterlane-cq__navbtn:focus-visible, .clusterlane-cq__close:focus-visible { background: rgba(255,255,255,.2); outline: none; }
+.clusterlane-cq__close { margin-left: 6px; font-size: 22px; }
+.clusterlane-cq__body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 22px 20px; }
+.clusterlane-cq__body.is-loading > * { opacity: .45; transition: opacity .15s; pointer-events: none; }
+.clusterlane-cq__body.is-loading { cursor: progress; }
+.clusterlane-cq__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 22px; border-top: 1px solid #e2e8f0; background: #fff; flex-shrink: 0; }
+.clusterlane-cq__keys { font-size: 11.5px; color: #94a3b8; }
+.clusterlane-cq__keys kbd { display: inline-block; min-width: 18px; padding: 1px 5px; margin-right: 3px; border: 1px solid #e2e8f0; border-bottom-width: 2px; border-radius: 4px; background: #f8fafc; font: inherit; font-size: 11px; text-align: center; color: #64748b; }
+.clusterlane-cq__open { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 8px; background: #2563eb; color: #fff; font-weight: 600; text-decoration: none; }
+.clusterlane-cq__open:hover, .clusterlane-cq__open:focus { background: #1d4ed8; color: #fff; }
+.clusterlane-cq__open .clusterlane-g { width: 15px; height: 15px; margin: 0; }
+
+.clusterlane-cq__loading, .clusterlane-cq__error { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 260px; margin: 0; color: #64748b; }
+.clusterlane-cq__error { color: #b91c1c; }
+.clusterlane-cq__spin { width: 18px; height: 18px; border: 2px solid #cbd5e1; border-top-color: #2563eb; border-radius: 50%; animation: clusterlane-cq-spin .8s linear infinite; }
+@keyframes clusterlane-cq-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .clusterlane-cq__spin { animation-duration: 2.4s; } }
+
+.clusterlane-cq__label { display: flex; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 600; color: #64748b; }
+.clusterlane-cq__top { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 10px; }
+.clusterlane-cq__health, .clusterlane-cq__tile { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; }
+.clusterlane-cq__health { padding: 14px 16px 10px; }
+.clusterlane-cq__hrow { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; margin: 2px 0 6px; }
+.clusterlane-cq__hval { font-size: 34px; line-height: 1.1; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
+.clusterlane-cq__health--good .clusterlane-cq__hval { color: #15803d; }
+.clusterlane-cq__health--fair .clusterlane-cq__hval { color: #b45309; }
+.clusterlane-cq__health--weak .clusterlane-cq__hval { color: #dc2626; }
+.clusterlane-cq__health--none .clusterlane-cq__hval { color: #94a3b8; }
+.clusterlane-cq__delta { font-size: 12.5px; font-weight: 700; padding: 1px 7px; border-radius: 999px; }
+.clusterlane-cq__delta--up { color: #15803d; background: #dcfce7; }
+.clusterlane-cq__delta--down { color: #b91c1c; background: #fee2e2; }
+.clusterlane-cq__delta--flat { color: #64748b; background: #f1f5f9; }
+.clusterlane-cq__since { font-size: 12px; color: #94a3b8; }
+.clusterlane-cq__spark { display: block; width: 100%; height: 46px; overflow: visible; }
+.clusterlane-cq__spark-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
+.clusterlane-cq__spark-area { opacity: .12; }
+.clusterlane-cq__spark--good .clusterlane-cq__spark-line { stroke: #16a34a; } .clusterlane-cq__spark--good .clusterlane-cq__spark-area { fill: #16a34a; }
+.clusterlane-cq__spark--fair .clusterlane-cq__spark-line { stroke: #d97706; } .clusterlane-cq__spark--fair .clusterlane-cq__spark-area { fill: #d97706; }
+.clusterlane-cq__spark--weak .clusterlane-cq__spark-line { stroke: #dc2626; } .clusterlane-cq__spark--weak .clusterlane-cq__spark-area { fill: #dc2626; }
+.clusterlane-cq__sparkaxis { display: flex; justify-content: space-between; font-size: 10.5px; color: #94a3b8; margin-top: 2px; }
+.clusterlane-cq__nohist { margin: 8px 0 4px; font-size: 12px; color: #94a3b8; }
+.clusterlane-cq__tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.clusterlane-cq__tile { padding: 11px 13px; min-width: 0; }
+.clusterlane-cq__tval { margin-top: 3px; font-size: 22px; line-height: 1.15; font-weight: 700; color: #0f172a; font-variant-numeric: tabular-nums; }
+.clusterlane-cq__tval.is-good { color: #15803d; } .clusterlane-cq__tval.is-fair { color: #b45309; } .clusterlane-cq__tval.is-weak { color: #dc2626; }
+.clusterlane-cq__tsub { margin-top: 2px; font-size: 11.5px; color: #94a3b8; }
+
+.clusterlane-cq__focus { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0 0; padding: 11px 14px; border-radius: 10px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a8a; line-height: 1.45; }
+.clusterlane-cq__focus .clusterlane-g { width: 18px; height: 18px; margin: 1px 0 0; flex-shrink: 0; color: #2563eb; }
+.clusterlane-cq__focus strong { margin-right: 4px; }
+.clusterlane-cq__focus--ok { background: #f0fdf4; border-color: #bbf7d0; color: #14532d; }
+.clusterlane-cq__focus--ok .clusterlane-g { color: #16a34a; }
+
+.clusterlane-cq__cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px 22px; margin-top: 4px; }
+.clusterlane-cq__sec { margin-top: 16px; }
+.clusterlane-cq__sec h3 { display: flex; align-items: center; gap: 4px; margin: 0 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: #64748b; }
+.clusterlane-cq__mix { display: flex; gap: 2px; height: 12px; border-radius: 6px; overflow: hidden; background: #e2e8f0; }
+.clusterlane-cq__seg { flex-basis: 0; min-width: 4px; }
+.clusterlane-cq__seg--pillar { background: #e76c2e; } .clusterlane-cq__seg--support { background: #f5c842; } .clusterlane-cq__seg--light { background: #90b865; } .clusterlane-cq__seg--orphan { background: #5ba4cf; }
+.clusterlane-cq__legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 0; padding: 0; list-style: none; font-size: 12.5px; color: #475569; }
+.clusterlane-cq__legend li { display: inline-flex; align-items: center; gap: 5px; margin: 0; }
+.clusterlane-cq__legend b { color: #0f172a; font-variant-numeric: tabular-nums; }
+.clusterlane-cq__legend li.is-zero { color: #94a3b8; } .clusterlane-cq__legend li.is-zero b { color: #94a3b8; font-weight: 500; }
+.clusterlane-cq__dl { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }
+.clusterlane-cq__dl > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 12px; border-top: 1px solid #f1f5f9; }
+.clusterlane-cq__dl > div:first-child { border-top: 0; }
+.clusterlane-cq__dl dt { color: #475569; }
+.clusterlane-cq__dl dt a { color: #2563eb; text-decoration: none; }
+.clusterlane-cq__dl dt a:hover { text-decoration: underline; }
+.clusterlane-cq__dl dd { margin: 0; font-weight: 700; color: #0f172a; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.clusterlane-cq__dl dd.is-weak { color: #dc2626; }
+.clusterlane-cq__meta { margin: 0; line-height: 1.6; color: #475569; }
+.clusterlane-cq__meta .is-weak { color: #dc2626; font-weight: 600; } .clusterlane-cq__meta .is-fair { color: #b45309; font-weight: 600; }
+.clusterlane-cq__meta a { display: inline-flex; align-items: center; gap: 3px; margin-left: 4px; color: #2563eb; font-weight: 600; text-decoration: none; white-space: nowrap; }
+.clusterlane-cq__meta a .clusterlane-g { width: 13px; height: 13px; margin: 0; }
+.clusterlane-cq__meta--ok { display: flex; align-items: center; gap: 6px; color: #15803d; }
+.clusterlane-cq__meta--ok .clusterlane-g { width: 15px; height: 15px; margin: 0; }
+.clusterlane-cq__muted { margin: 0; color: #94a3b8; line-height: 1.5; }
+.clusterlane-cq__list { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }
+.clusterlane-cq__art { display: flex; align-items: center; gap: 10px; padding: 8px 12px; color: #1e293b; text-decoration: none; border-top: 1px solid #f1f5f9; }
+.clusterlane-cq__list .clusterlane-cq__art:first-child { border-top: 0; }
+.clusterlane-cq__sec > .clusterlane-cq__art { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 6px; }
+.clusterlane-cq__art:hover, .clusterlane-cq__art:focus-visible { background: #f8fafc; color: #1d4ed8; outline: none; }
+.clusterlane-cq__art:focus-visible { box-shadow: inset 0 0 0 2px #2563eb; }
+.clusterlane-cq__arttext { flex: 1; min-width: 0; }
+.clusterlane-cq__artname { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; }
+.clusterlane-cq__artwhy { display: flex; align-items: center; gap: 5px; margin-top: 2px; font-size: 11.5px; color: #64748b; }
+.clusterlane-cq__go { flex-shrink: 0; color: #cbd5e1; }
+.clusterlane-cq__go .clusterlane-g { width: 14px; height: 14px; margin: 0; }
+.clusterlane-cq__art:hover .clusterlane-cq__go { color: #2563eb; }
+.clusterlane-cq__more { margin: 8px 0 0; font-size: 12px; color: #64748b; }
+
+@media (max-width: 782px) {
+    .clusterlane-cq-overlay { padding: 0; align-items: stretch; }
+    .clusterlane-cq { max-width: none; max-height: none; height: 100%; border-radius: 0; }
+    .clusterlane-cq__head { padding: 12px 12px 12px 16px; }
+    .clusterlane-cq__title { font-size: 17px; }
+    .clusterlane-cq__pos { display: none; }
+    .clusterlane-cq__body { padding: 14px 16px 18px; }
+    .clusterlane-cq__top, .clusterlane-cq__cols { grid-template-columns: 1fr; }
+    .clusterlane-cq__keys { display: none; }
+    .clusterlane-cq__foot { justify-content: flex-end; padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
+    .clusterlane-cq__open { flex: 1; justify-content: center; }
+}
+
+/* =========================================================
+   Phones (1.2.1): Missing content, Pillar & split check and the
+   AI Link Suggestion modal stack instead of squeezing the text
+   into a narrow column next to the buttons.
+   ========================================================= */
+@media (max-width: 782px) {
+    /* Missing content: text full width, buttons underneath. */
+    .clusterlane-gap-item { flex-direction: column; align-items: stretch; gap: 8px; }
+    .clusterlane-gap-item-actions { flex-shrink: 1; }
+
+    /* Pillar & split check: the summary gets the full width, Cached + Regenerate below it. */
+    .clusterlane-split-head { flex-wrap: wrap; row-gap: 8px; }
+    .clusterlane-split-note { flex: 1 1 100%; }
+
+    /* AI Link Suggestion modal: full screen, controls first, the live preview below. */
+    .clusterlane-ls-modal-overlay { padding: 0; align-items: stretch; }
+    .clusterlane-ls-modal { max-height: none; height: 100%; border-radius: 0; }
+    .clusterlane-ls-modal-header { padding: 14px 16px; }
+    .clusterlane-ls-modal-body { flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; }
+    .clusterlane-ls-modal-controls-col { order: 1; flex: none; min-width: 0; max-width: none; padding: 14px 16px 18px; overflow: visible; }
+    .clusterlane-ls-modal-preview-col { order: 2; flex: none; padding: 14px 16px 20px; overflow: visible; border-right: 0; border-top: 1px solid #eef0f3; }
+    .clusterlane-ls-preview-frame { min-height: 0; }
+    .clusterlane-ls-preview-iframe { height: 420px; }
+    .clusterlane-ls-modal-controls-col .clusterlane-ls-apply-final-btn { margin-top: 18px; }
+}
+
+/* ── Moved from an inline <style> in class-cluster-settings-page.php (1.3.0) ── */
+.clusterlane-settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 8px; }
+@media (max-width: 900px) { .clusterlane-settings-grid { grid-template-columns: 1fr; } }
+.clusterlane-checkbox-list { max-height: 320px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; padding: 4px 8px; margin-top: 10px; }
+.clusterlane-checkbox-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px; padding: 7px 4px; border-bottom: 1px solid #f3f3f3; font-size: .88rem; }
+.clusterlane-checkbox-row:last-child { border-bottom: none; }
+.clusterlane-checkbox-count { color: #94a3b8; font-size: .78rem; white-space: nowrap; }
+.clusterlane-settings-actions { margin-top: 10px; display: flex; gap: 8px; }
+.clusterlane-overlap-legend { font-size: .78rem; color: #888; margin: 6px 0 4px; }
+.clusterlane-overlap-badge { display: inline-block; width: 9px; height: 9px; border-radius: 50%; vertical-align: middle; }
+.clusterlane-overlap-low  { background: #10b981; }
+.clusterlane-overlap-mid  { background: #f59e0b; }
+.clusterlane-overlap-high { background: #ef4444; }
+
+/* Three-type explainer row */
+.clusterlane-type-explainer { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 28px; }
+@media (max-width: 900px) { .clusterlane-type-explainer { grid-template-columns: 1fr; } }
+.clusterlane-type-card {
+    display: flex; align-items: flex-start; gap: 12px;
+    background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
+    padding: 14px 16px;
+}
+.clusterlane-type-icon { font-size: 22px; line-height: 1; flex-shrink: 0; margin-top: 2px; }
+.clusterlane-type-card strong { display: block; font-size: 13px; color: #0f172a; margin-bottom: 3px; }
+.clusterlane-type-card p { margin: 0; font-size: 12px; color: #64748b; line-height: 1.4; }
+
+/* Section headings that own their own save/action button, so the
+   two independent cluster-management flows (auto vs. custom)
+   read as clearly separate rather than one continuous form. */
+.clusterlane-section-heading { font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; color: #0f172a; margin: 0 0 12px; }
+.clusterlane-section-sub { font-size: 12px; font-weight: 400; color: #94a3b8; margin-left: 4px; }
+.clusterlane-section-divider { border: none; border-top: 1px solid #e2e8f0; margin: 32px 0 24px; }
+
+/* ── Moved from an inline <style> in class-settings-ai.php (1.3.0) ── */
+.clusterlane-lt-row {
+                    border: 1px solid #eef0f3; border-radius: 10px; padding: 14px 16px; margin-bottom: 14px;
+                }
+                .clusterlane-lt-row-top { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
+                .clusterlane-lt-name-input { flex: 1; font-weight: 600; }
+                .clusterlane-lt-remove {
+                    background: none; border: 1px solid #e2e8f0; border-radius: 6px;
+                    width: 30px; height: 30px; cursor: pointer; color: #94a3b8; flex-shrink: 0;
+                }
+                .clusterlane-lt-remove:hover { color: #b32d2e; border-color: #fca5a5; background: #fef2f2; }
+                .clusterlane-lt-preview-label {
+                    font-size: .7rem; text-transform: uppercase; letter-spacing: .04em;
+                    font-weight: 700; color: #94a3b8; margin-bottom: 6px;
+                }
+                .clusterlane-lt-live-preview { margin-bottom: 10px; }
+                .clusterlane-lt-live-preview blockquote { margin: 0 !important; }
+.clusterlane-lt-transfer { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 16px; padding-top: 14px; border-top: 1px solid #eef0f3; }
+                .clusterlane-lt-transfer__label { font-size: 13px; color: #64748b; }
+                .clusterlane-lt-transfer__status { font-size: 13px; color: #15803d; }
+                .clusterlane-lt-transfer__status.is-error { color: #b32d2e; }
+                .clusterlane-lt-row.is-imported { border-color: #86efac; background: #f0fdf4; }
+                .clusterlane-lt-edit-toggle {
+                    background: none; border: none; color: #2271b1; font-size: .8rem; font-weight: 600;
+                    cursor: pointer; padding: 4px 0;
+                }
Index: /clusterlane/tags/1.3.2/admin/css/admin.min.css
===================================================================
--- /clusterlane/tags/1.3.2/admin/css/admin.min.css	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/css/admin.min.css	(revision 3751027)
@@ -0,0 +1,1 @@
+.clusterlane-site-analysis-card{background:#fff;border:1.5px solid #e2e8f0;border-radius:14px;margin-bottom:20px;box-shadow:0 2px 8px rgba(15,23,42,.06)}.clusterlane-site-analysis-header{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:22px 28px;cursor:pointer;list-style:none;border-radius:14px}.clusterlane-site-analysis-header::-webkit-details-marker{display:none}.clusterlane-site-analysis-header::after{content:"▸";font-size:.95rem;color:#94a3b8;transition:transform 120ms;flex-shrink:0}details.clusterlane-site-analysis-card[open]>.clusterlane-site-analysis-header::after{transform:rotate(90deg)}details.clusterlane-site-analysis-card[open]>.clusterlane-site-analysis-header{border-radius:14px 14px 0 0;border-bottom:1px solid #f1f5f9}.clusterlane-site-analysis-title{display:flex;align-items:center;gap:14px}.clusterlane-site-analysis-icon{font-size:1.7rem;line-height:1;flex-shrink:0}.clusterlane-site-analysis-title strong{display:block;font-size:16px;font-weight:700;color:#0f172a;letter-spacing:-.01em}.clusterlane-site-analysis-sub{font-size:12.5px;color:#64748b;margin-top:2px}#clusterlane-site-analysis-body{padding:4px 28px 28px}.clusterlane-site-analysis-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}.clusterlane-site-analysis-meta{font-size:11px;color:#94a3b8}.clusterlane-sa-btn-wrap{display:flex;flex-direction:column;align-items:center;gap:4px}.clusterlane-sa-ai-badge{font-size:10px;color:#94a3b8;letter-spacing:.02em;white-space:nowrap}.clusterlane-ai-vital-inc{font-style:normal;font-size:11px;color:#94a3b8;margin-left:2px}.clusterlane-sa-ai-badge--pro{font-size:12px;color:#6d28d9;font-weight:600}.clusterlane-site-analysis-body-inner{margin-top:0;border-top:none;padding-top:4px;display:grid;grid-template-columns:1fr 1fr;gap:24px}@media (max-width:800px){.clusterlane-site-analysis-body-inner{grid-template-columns:1fr}}.clusterlane-sa-overview{grid-column:1/-1;font-size:13.5px;line-height:1.7;color:#1e293b;background:#f8fafc;border-left:3px solid #2271b1;border-radius:0 6px 6px 0;padding:14px 18px;margin-bottom:4px}.clusterlane-sa-overview .clusterlane-sa-section-title{text-transform:none;letter-spacing:0;font-size:14px;font-weight:700;color:#1e3a8a;margin-bottom:8px}.clusterlane-sa-section-title{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#334155;margin:0 0 12px;display:flex;align-items:center;gap:6px}.clusterlane-sa-strengths{grid-column:1/-1}.clusterlane-sa-strength-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}.clusterlane-sa-strength{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:10px 12px}.clusterlane-sa-strength strong{display:block;font-size:12.5px;color:#14532d;margin-bottom:4px}.clusterlane-sa-strength span{font-size:12px;color:#166534;line-height:1.45}.clusterlane-sa-opportunities{grid-column:1/-1}.clusterlane-sa-opp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}.clusterlane-sa-opp{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 12px}.clusterlane-sa-opp strong{display:block;font-size:12.5px;color:#92400e;margin-bottom:4px}.clusterlane-sa-opp span{font-size:12px;color:#78350f;line-height:1.45}.clusterlane-sa-weak{grid-column:1/-1}.clusterlane-sa-weak-chips{display:flex;flex-wrap:wrap;gap:6px}.clusterlane-sa-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;font-size:11.5px;font-weight:600}.clusterlane-sa-chip em{font-style:normal;font-weight:700;opacity:.75}.clusterlane-sa-chip--warn{background:#fef3c7;color:#92400e;border:1px solid #fde68a}.clusterlane-sa-chip--critical{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}.clusterlane-sa-actions{grid-column:1/-1}.clusterlane-sa-action-list{display:flex;flex-direction:column;gap:8px}.clusterlane-sa-action{display:flex;align-items:flex-start;gap:10px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:10px 12px}.clusterlane-sa-action-num{flex-shrink:0;width:22px;height:22px;background:#2563eb;color:#fff;border-radius:50%;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center}.clusterlane-sa-action-text strong{display:block;font-size:13px;color:#1e3a8a;margin-bottom:2px}.clusterlane-sa-action-text span{font-size:12px;color:#1d4ed8;line-height:1.45}.clusterlane-wrap{max-width:1200px}.clusterlane-title{display:flex;align-items:center;gap:8px;font-size:1.6rem!important;font-weight:800!important;letter-spacing:-.01em;color:#16223a;margin-bottom:24px!important}.clusterlane-title .dashicons{font-size:1.4rem;width:1.4rem;height:1.4rem;color:#2271b1}.clusterlane-scan-section{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:22px 24px;margin-top:24px;box-shadow:0 1px 3px rgba(15,23,42,.06),0 4px 12px rgba(15,23,42,.06)}.clusterlane-scan-section h2{margin-top:0}.clusterlane-scan-section h2{font-size:1.3rem;font-weight:800;letter-spacing:-.01em;color:#0f172a}.clusterlane-action-btn{display:inline-flex;align-items:center;gap:7px;min-width:158px;justify-content:center;padding:9px 16px;border-radius:8px;font-size:13px;font-weight:600;line-height:1;cursor:pointer;text-decoration:none;transition:background 120ms,box-shadow 120ms,opacity 120ms;white-space:nowrap;box-sizing:border-box}.clusterlane-action-btn:focus-visible{outline:2px solid #2271b1;outline-offset:2px}.clusterlane-action-btn--primary{background:#2563eb;color:#fff;border:1.5px solid transparent;box-shadow:0 1px 3px rgba(37,99,235,.25),0 3px 8px rgba(37,99,235,.15)}.clusterlane-action-btn--primary:hover{background:#1d4ed8;box-shadow:0 2px 6px rgba(37,99,235,.3),0 4px 12px rgba(37,99,235,.2);color:#fff;text-decoration:none}.clusterlane-action-btn--primary:disabled,.clusterlane-action-btn--primary[disabled]{background:#e2e8f0;color:#94a3b8;opacity:1;cursor:not-allowed;box-shadow:none}.clusterlane-action-btn--secondary{background:#fff;color:#1e3a5f;border:1.5px solid #c4d4e8;box-shadow:0 1px 2px rgba(15,23,42,.05)}.clusterlane-action-btn--secondary:hover{background:#f0f6ff;border-color:#93b8d8;color:#1e3a5f;text-decoration:none;box-shadow:0 2px 6px rgba(15,23,42,.08)}.clusterlane-action-btn--secondary:disabled,.clusterlane-action-btn--secondary[disabled]{opacity:.5;cursor:not-allowed;box-shadow:none}.clusterlane-action-btn--tertiary{background:#f8fafc;color:#475569;border:1.5px solid #e2e8f0}.clusterlane-action-btn--tertiary:hover{background:#f1f5f9;color:#1e293b;border-color:#cbd5e1;text-decoration:none}.clusterlane-action-btn--danger{background:#fff0f0;color:#b32d2e;border:1.5px solid #f5c0c0}.clusterlane-action-btn--danger:hover{background:#ffe4e4;border-color:#f4a0a0;color:#9a1f1f;text-decoration:none}.clusterlane-action-btn__icon{display:flex;align-items:center;flex-shrink:0;opacity:.85}.clusterlane-action-btn--primary .clusterlane-action-btn__icon{opacity:.9}@media (max-width:700px){.clusterlane-action-btn{min-width:0;flex:1 1 calc(50% - 4px)}}.clusterlane-progress-bar-wrap{background:#f0f0f0;border-radius:4px;height:10px;overflow:hidden;margin-bottom:8px}.clusterlane-progress-bar{height:100%;background:#2271b1;border-radius:4px;transition:width .3s ease}.clusterlane-progress-label{font-size:.82rem;color:#666}.clusterlane-scan-status{margin-top:12px;font-size:.88rem;color:#444;min-height:20px}.clusterlane-scan-status.success{color:#1a7f37}.clusterlane-scan-status.error{color:#b32d2e}.clusterlane-scan-status.warn{color:#b45309}.clusterlane-archetype-pill{display:inline-block;font-size:.75rem;font-weight:600;padding:2px 8px;border-radius:10px;background:#e8e8e8;color:#444}.clusterlane-archetype-balanced-mesh{background:#d4edda;color:#155724}.clusterlane-archetype-developed-cluster{background:#cce5ff;color:#004085}.clusterlane-archetype-authority-pyramid{background:#d1ecf1;color:#0c5460}.clusterlane-archetype-emerging-cluster{background:#e8e8e8;color:#555}.clusterlane-archetype-weak-structure{background:#fff3cd;color:#856404}.clusterlane-archetype-fragmented{background:#f8d7da;color:#721c24}.clusterlane-archetype-over-centralized-hub{background:#fce8d5;color:#7a3b00}.clusterlane-panel-card{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:22px 24px;box-shadow:0 1px 3px rgba(15,23,42,.06),0 4px 12px rgba(15,23,42,.06)}.clusterlane-panel-card h2{margin:0 0 18px;font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#94a3b8}.clusterlane-arch-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}.clusterlane-search-wrap{position:relative;width:100%;max-width:340px}.clusterlane-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#9ca3af;pointer-events:none;transition:color .15s}.clusterlane-search-wrap .clusterlane-search-input{width:100%;height:34px;padding:0 12px 0 38px;border:1.5px solid #cbd5e1;border-radius:6px;font-size:13px;font-family:inherit;color:#0f172a;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.04);box-sizing:border-box;transition:border-color .15s,box-shadow .15s}.clusterlane-search-wrap .clusterlane-search-input::placeholder{color:#94a3b8}.clusterlane-search-wrap .clusterlane-search-input:hover{border-color:#94a3b8}.clusterlane-search-wrap .clusterlane-search-input:focus{outline:0;border-color:#2271b1;box-shadow:0 0 0 2px rgba(34,113,177,.15)}.clusterlane-search-wrap:focus-within .clusterlane-search-icon{color:#2271b1}.clusterlane-type-filters{display:flex;gap:6px}.clusterlane-type-filter{background:#fff;border:1px solid #e2e8f0;border-radius:999px;padding:6px 14px;font-size:12px;font-weight:600;color:#64748b;cursor:pointer;transition:all .15s}.clusterlane-type-filter:hover{border-color:#94a3b8;color:#334155}.clusterlane-type-filter.active{background:#16223a;border-color:#16223a;color:#fff}.clusterlane-hero{background:linear-gradient(135deg,#16223a 0,#22314f 100%);border-radius:18px;padding:28px 32px 32px;color:#fff;box-shadow:0 12px 32px rgba(15,23,42,.2);margin-bottom:28px}.clusterlane-hero-top{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:24px}.clusterlane-hero-title{display:flex;align-items:center;gap:14px}.clusterlane-hero-title .dashicons{font-size:32px;width:32px;height:32px;color:#60a5fa}.clusterlane-hero-title h1{margin:0;font-size:26px;font-weight:900;color:#fff;letter-spacing:-.03em;line-height:1.1;text-shadow:0 1px 4px rgba(0,0,0,.2)}.clusterlane-hero-version{display:inline-block;margin-top:2px;font-size:11px;font-weight:600;color:#93c5fd;background:rgba(96,165,250,.15);padding:1px 8px;border-radius:999px}.clusterlane-hero-lastscan{font-size:12px;color:#94a3b8;padding-top:6px}.clusterlane-hero-body{display:grid;grid-template-columns:1.3fr auto 1.4fr;gap:28px;align-items:center}@media (max-width:1000px){.clusterlane-hero-body{grid-template-columns:1fr}}.clusterlane-hero-stats{display:grid;grid-template-columns:repeat(3,auto);gap:18px 28px}.clusterlane-hero-stat{display:flex;flex-direction:column;gap:2px}.clusterlane-hero-stat-value{font-size:24px;font-weight:800;color:#fff;line-height:1}.clusterlane-hero-stat-value--green{color:#34d399}.clusterlane-hero-stat-value--amber{color:#fbbf24}.clusterlane-hero-stat-value--red{color:#f87171}.clusterlane-hero-stat-label{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#94a3b8}.clusterlane-hero-health{display:flex;flex-direction:column;align-items:center;gap:10px}.clusterlane-hero-ring{position:relative;width:132px;height:132px}.clusterlane-hero-ring svg{transform:rotate(-90deg)}.clusterlane-hero-ring-bg{fill:none;stroke:rgba(255,255,255,.10);stroke-width:12}.clusterlane-hero-ring-fill{fill:none;stroke-width:12;stroke-linecap:round;transition:stroke-dashoffset .6s ease}.clusterlane-hero-ring-label{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1}.clusterlane-hero-ring-value{font-size:28px;font-weight:800}.clusterlane-hero-ring-sub{font-size:10px;text-transform:uppercase;color:#94a3b8;letter-spacing:.06em;margin-top:4px}.clusterlane-hero-health-desc{font-size:12px;color:#94a3b8;text-align:center;max-width:160px}.clusterlane-hero-archetypes{display:flex;flex-direction:column;gap:10px}.clusterlane-hero-archetypes-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#94a3b8;font-weight:700}.clusterlane-arch-stacked-bar{display:flex;width:100%;height:10px;border-radius:999px;overflow:hidden;background:rgba(255,255,255,.08)}.clusterlane-arch-seg{height:100%;transition:width .4s ease}.clusterlane-arch-stacked-legend{display:flex;flex-wrap:wrap;gap:8px 16px}.clusterlane-arch-legend-item{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#cbd5e1}.clusterlane-arch-legend-item strong{color:#fff;font-weight:700}.clusterlane-row-btn{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;border:1px solid #e2e8f0;border-radius:999px;background:#fff;font-size:13px;line-height:1;cursor:pointer;transition:transform .12s,box-shadow .12s,background .12s,border-color .12s;padding:0}.clusterlane-row-btn:hover{transform:translateY(-1px);box-shadow:0 2px 8px rgba(15,23,42,.12)}.clusterlane-row-btn--delete:hover{background:#fef2f2;border-color:#fca5a5}.clusterlane-modal-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding-right:28px}.clusterlane-modal-title{margin:0;font-size:1.3rem;font-weight:800;letter-spacing:-.01em;color:#16223a}.clusterlane-modal-stats{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;padding-top:5px}.clusterlane-modal-stat{font-size:.78rem;color:#64748b;white-space:nowrap}.clusterlane-modal-stat strong{color:#0f172a;font-weight:700}.clusterlane-modal-stat--type{font-weight:600;color:#475569;background:#f1f5f9;padding:3px 10px;border-radius:999px}.clusterlane-modal-member-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;border-bottom:1px solid #f5f5f5}.clusterlane-modal-member-row:last-child{border-bottom:none}.clusterlane-modal-member-title{font-size:.85rem;color:#1e293b}.clusterlane-modal-member-row .clusterlane-row-btn{width:26px;height:26px;font-size:12px;flex-shrink:0}.clusterlane-graph-expand-btn{position:absolute;top:10px;right:10px;z-index:5;background:#1d2327;color:#fff;border:none;border-radius:999px;padding:6px 14px;font-size:.78rem;font-weight:600;cursor:pointer;box-shadow:0 2px 8px rgba(15,23,42,.18);transition:background .15s,transform .12s}.clusterlane-graph-expand-btn:hover{background:#16223a;transform:translateY(-1px)}.clusterlane-modal-stat--graph-first{border-left:1px solid #e2e8f0;padding-left:10px}.clusterlane-graph-overlay{margin-top:10px;background:#fff;border:1px solid #eef0f3;border-radius:12px;padding:8px 12px;box-shadow:0 4px 14px -6px rgba(15,23,41,.12),0 1px 4px rgba(15,23,41,.04)}.clusterlane-graph-status{font-size:.72rem;color:#7c8592;white-space:nowrap}.clusterlane-graph-legend{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;padding-top:6px;border-top:1px solid #f1f3f5}.clusterlane-graph-legend-item{display:inline-flex;align-items:center;gap:5px;font-size:10.5px;color:#475569;background:#f8fafc;border:1px solid #eef0f3;border-radius:999px;padding:3px 9px 3px 7px;white-space:nowrap}.clusterlane-gap-loading{display:flex;align-items:center;gap:10px;padding:24px 8px;color:#64748b;font-size:.88rem}.clusterlane-gap-spinner{width:15px;height:15px;border:2px solid #e2e8f0;border-top-color:#16223a;border-radius:50%;display:inline-block;animation:clusterlane-spin .7s linear infinite}@keyframes clusterlane-spin{to{transform:rotate(360deg)}}.clusterlane-gap-error{padding:12px 14px;background:#fef2f2;border:1px solid #fecaca;border-radius:10px;color:#991b1b;font-size:.85rem;line-height:1.6}.clusterlane-gap-error a,.clusterlane-gap-retry{color:#b91c1c;font-weight:600;text-decoration:underline;background:0 0;border:none;padding:0;cursor:pointer;font-size:.85rem}.clusterlane-gap-toolbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.clusterlane-gap-badge{display:inline-block;font-size:.72rem;font-weight:600;padding:3px 10px;border-radius:999px}.clusterlane-gap-badge--cached{background:#f1f5f9;color:#64748b}.clusterlane-gap-badge--fresh{background:#dcfce7;color:#15803d}.clusterlane-gap-regenerate{background:#fff;border:1px solid #e2e8f0;border-radius:999px;padding:5px 12px;font-size:.78rem;font-weight:600;color:#475569;cursor:pointer;transition:border-color .15s,color .15s}.clusterlane-gap-regenerate:hover{border-color:#94a3b8;color:#16223a}.clusterlane-gap-analysis{padding:12px 14px;background:#f0f6fc;border-left:3px solid #2271b1;border-radius:0 10px 10px 0;font-size:.87rem;color:#1d2327;line-height:1.6;margin-bottom:16px}.clusterlane-gap-suggestions{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}.clusterlane-gap-card{border:1px solid #eef0f3;border-radius:12px;padding:14px;background:#fff;box-shadow:0 1px 3px rgba(15,23,41,.04);display:flex;flex-direction:column;gap:8px}.clusterlane-gap-card-title{font-weight:700;font-size:.92rem;color:#16223a;line-height:1.35}.clusterlane-gap-card-rationale{font-size:.8rem;color:#64748b;line-height:1.5;flex:1}.clusterlane-gap-draft-btn{align-self:flex-start;background:#16223a;color:#fff;border:none;border-radius:999px;padding:7px 14px;font-size:.78rem;font-weight:600;cursor:pointer;transition:background .15s,transform .12s}.clusterlane-gap-draft-btn:hover{background:#1d2b45;transform:translateY(-1px)}.clusterlane-gap-draft-btn:disabled{opacity:.6;cursor:default;transform:none}.clusterlane-gap-draft-done{align-self:flex-start;font-size:.78rem;font-weight:600;color:#15803d;text-decoration:none;background:#dcfce7;border-radius:999px;padding:7px 14px}.clusterlane-gap-draft-done:hover{text-decoration:underline}.clusterlane-ls-search-results{position:relative;max-width:420px;margin-top:6px;border:1px solid #eef0f3;border-radius:10px;background:#fff;box-shadow:0 8px 20px -6px rgba(15,23,41,.14);max-height:240px;overflow-y:auto;z-index:5}.clusterlane-ls-modal-overlay{position:fixed;inset:0;background:rgba(15,23,41,.55);z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px}.clusterlane-ls-modal{background:#fff;border-radius:12px;width:100%;max-width:1320px;max-height:92vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px -10px rgba(15,23,41,.35)}.clusterlane-ls-modal-header{display:flex;align-items:flex-start;justify-content:space-between;padding:20px 24px;border-bottom:1px solid #eef0f3;flex-shrink:0}.clusterlane-ls-modal-header h2{margin:0 0 2px;font-size:1.15rem;font-weight:800;color:#16223a}.clusterlane-ls-modal-close{background:0 0;border:none;font-size:1.5rem;line-height:1;color:#94a3b8;cursor:pointer;padding:2px 6px}.clusterlane-ls-modal-close:hover{color:#16223a}.clusterlane-ls-modal-body{display:flex;flex:1;overflow:hidden}.clusterlane-ls-modal-preview-col{flex:1.6;min-width:0;padding:20px 24px;overflow-y:auto;border-right:1px solid #eef0f3}.clusterlane-ls-modal-controls-col{flex:1;min-width:300px;max-width:380px;padding:20px 24px;overflow-x:hidden;overflow-y:auto;background:#fafbfc;display:flex;flex-direction:column;box-sizing:border-box}.clusterlane-ls-modal-section-label{font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;font-weight:700;color:#94a3b8;margin-bottom:4px}.clusterlane-ls-preview-frame{margin-top:10px;border:1px solid #eef0f3;border-radius:10px;overflow:hidden;min-height:500px}.clusterlane-ls-preview-frame .clusterlane-gap-error,.clusterlane-ls-preview-frame .clusterlane-gap-loading{margin:20px 24px}.clusterlane-ls-preview-iframe{display:block;width:100%;height:560px;border:none}.clusterlane-ls-field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;font-weight:700;color:#64748b;margin:14px 0 4px}.clusterlane-ls-field-input,.clusterlane-ls-field-textarea{width:100%;padding:7px 11px;border:1.5px solid #cbd5e1;border-radius:6px;font-size:13px;font-family:inherit;background:#fff;box-sizing:border-box}.clusterlane-ls-field-textarea{resize:vertical}.clusterlane-ls-field-input:focus,.clusterlane-ls-field-textarea:focus{outline:0;border-color:#2271b1;box-shadow:0 0 0 2px rgba(34,113,177,.15)}.clusterlane-ls-apply-final-btn{width:100%;margin-top:auto;background:#16223a;color:#fff;border:none;border-radius:8px;padding:11px 16px;font-size:.88rem;font-weight:700;cursor:pointer;transition:background .15s}.clusterlane-ls-apply-final-btn:hover{background:#1d2b45}.clusterlane-ls-apply-final-btn:disabled{opacity:.65;cursor:default}.clusterlane-ls-apply-status{margin-top:10px;font-size:.82rem}.clusterlane-ls-apply-success{color:#15803d;font-weight:600}.clusterlane-ls-apply-success a{color:#15803d;text-decoration:underline}.clusterlane-wrap select.clusterlane-tms-select{font-size:12.5px!important;font-weight:500!important;color:#0f172a!important;background-color:#fff!important;border:1.5px solid #cbd5e1!important;border-radius:6px!important;padding:6px 28px 6px 10px!important;height:auto!important;line-height:1.4!important;appearance:none!important;-webkit-appearance:none!important;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 16 16'%3E%3Cpath fill='%2364748b' d='M4 6l4 4 4-4'/%3E%3C/svg%3E")!important;background-repeat:no-repeat!important;background-position:right 9px center!important;cursor:pointer!important;min-width:155px!important;box-shadow:0 1px 2px rgba(15,23,42,.05)!important;transition:border-color .15s,box-shadow .15s}.clusterlane-wrap select.clusterlane-tms-select:hover{border-color:#94a3b8!important}.clusterlane-wrap select.clusterlane-tms-select:focus{outline:0!important;border-color:#16223a!important;box-shadow:0 0 0 3px rgba(15,25,41,.08)!important}.clusterlane-wrap select.clusterlane-tms-select:disabled{opacity:.45!important;cursor:default!important}.clusterlane-settings-section .form-table{border-collapse:collapse;width:100%}.clusterlane-settings-section .form-table th{width:200px;padding:12px 16px 12px 0;font-size:13px;font-weight:600;color:#374151;vertical-align:top;text-align:left}.clusterlane-settings-section .form-table td{padding:10px 0 14px;vertical-align:top}.clusterlane-settings-section .form-table tr+tr td,.clusterlane-settings-section .form-table tr+tr th{border-top:1px solid #f1f5f9}.clusterlane-wrap .clusterlane-settings-section input[type=number],.clusterlane-wrap .clusterlane-settings-section input[type=password],.clusterlane-wrap .clusterlane-settings-section input[type=text],.clusterlane-wrap .clusterlane-settings-section select,.clusterlane-wrap .clusterlane-settings-section textarea{font-size:13px!important;font-family:inherit!important;color:#0f172a!important;background:#fff!important;border:1.5px solid #cbd5e1!important;border-radius:6px!important;padding:7px 11px!important;height:36px!important;line-height:20px!important;box-shadow:0 1px 2px rgba(15,23,42,.04)!important;transition:border-color .15s,box-shadow .15s!important;box-sizing:border-box!important}.clusterlane-wrap .clusterlane-settings-section input[type=number]:focus,.clusterlane-wrap .clusterlane-settings-section input[type=password]:focus,.clusterlane-wrap .clusterlane-settings-section input[type=text]:focus,.clusterlane-wrap .clusterlane-settings-section select:focus,.clusterlane-wrap .clusterlane-settings-section textarea:focus{outline:0!important;border-color:#2271b1!important;box-shadow:0 0 0 2px rgba(34,113,177,.15)!important}.clusterlane-wrap .clusterlane-settings-section select{appearance:none!important;-webkit-appearance:none!important;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' viewBox='0 0 16 16'%3E%3Cpath fill='%2364748b' d='M4 6l4 4 4-4'/%3E%3C/svg%3E")!important;background-repeat:no-repeat!important;background-position:right 10px center!important;padding-right:28px!important}.clusterlane-wrap .clusterlane-settings-section textarea{resize:vertical!important;height:auto!important;min-height:80px!important}.clusterlane-settings-section .description,.clusterlane-settings-section p.description{font-size:12px!important;color:#64748b!important;margin-top:5px!important;line-height:1.5!important}.clusterlane-settings-section input[type=checkbox]{margin-right:5px!important;accent-color:#16223a!important}.clusterlane-settings-section input[type=range]{accent-color:#16223a!important;height:4px!important;border:none!important;box-shadow:none!important;padding:0!important;background:0 0!important}.clusterlane-settings-number{width:80px!important}.clusterlane-settings-number--sm{width:65px!important}.clusterlane-settings-section{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:0;margin-bottom:16px;box-shadow:0 1px 3px rgba(15,23,42,.06),0 4px 12px rgba(15,23,42,.06)}.clusterlane-settings-summary{list-style:none;display:flex;align-items:center;padding:16px 22px;cursor:pointer;border-radius:18px;user-select:none}.clusterlane-settings-summary::-webkit-details-marker{display:none}.clusterlane-settings-summary::after{content:"▸";margin-left:auto;font-size:.95rem;color:#94a3b8;transition:transform 120ms}details[open]>.clusterlane-settings-summary::after{transform:rotate(90deg)}details[open]>.clusterlane-settings-summary{border-radius:18px 18px 0 0;border-bottom:1px solid #f1f5f9}.clusterlane-settings-section h2{margin:0;font-size:1rem;font-weight:700;letter-spacing:-.01em;color:#0f172a}.clusterlane-section-body{padding:14px 22px 18px}.clusterlane-lt-row{border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;margin-bottom:12px;background:#fff}.clusterlane-lt-card-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}.clusterlane-lt-name-input{flex:1;font-size:13px;font-weight:600}.clusterlane-lt-preview-area{margin:8px 0 10px;font-size:13px;min-height:20px}.clusterlane-lt-preview-area blockquote{margin:0!important}.clusterlane-lt-edit-details{border-top:1px solid #f1f5f9;padding-top:10px;margin-top:4px}.clusterlane-lt-edit-details>summary.clusterlane-lt-edit-summary{font-size:12px;font-weight:600;color:#64748b;cursor:pointer;list-style:none;display:flex;align-items:center;gap:5px;user-select:none}.clusterlane-lt-edit-details>summary.clusterlane-lt-edit-summary::-webkit-details-marker{display:none}.clusterlane-lt-edit-details>summary.clusterlane-lt-edit-summary::before{content:"▸";font-size:10px;transition:transform 120ms}.clusterlane-lt-edit-details[open]>summary.clusterlane-lt-edit-summary::before{transform:rotate(90deg)}.clusterlane-lt-controls{margin-top:12px}.clusterlane-lt-control-row{display:flex;flex-wrap:wrap;gap:12px;margin-bottom:10px;align-items:flex-end}.clusterlane-lt-control-label{display:flex;flex-direction:column;gap:4px;font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:.04em}.clusterlane-lt-control-label input[type=text]{font-size:12.5px!important;padding:5px 8px!important;width:140px}.clusterlane-lt-control-label input[type=color]{width:80px;height:30px;padding:2px!important;cursor:pointer;border-radius:5px!important}.clusterlane-lt-control-label input[type=number]{font-size:12.5px!important;padding:5px 8px!important}.clusterlane-lt-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12.5px;color:#374151;margin-bottom:10px;cursor:pointer}.clusterlane-lt-raw-details{margin-top:10px;border-top:1px solid #f1f5f9;padding-top:8px}.clusterlane-lt-raw-summary{font-size:11px;font-weight:600;color:#94a3b8;cursor:pointer;list-style:none}.clusterlane-lt-raw-summary::-webkit-details-marker{display:none}.clusterlane-lt-placeholder-hint{font-size:11px;color:#94a3b8;margin:6px 0;line-height:1.6}.clusterlane-lt-raw-html{width:100%;font-family:monospace;font-size:12px;margin-top:6px;resize:vertical}.clusterlane-lt-intro{font-size:13px;color:#475569;margin-bottom:14px;padding:10px 14px;background:#f8fafc;border-radius:7px;border-left:3px solid #cbd5e1}.clusterlane-ls-result-item{padding:10px 14px;font-size:.855rem;color:#1e293b;cursor:pointer;border-bottom:1px solid #f5f5f5;line-height:1.4}.clusterlane-ls-result-item:last-child{border-bottom:none}.clusterlane-ls-result-item:focus,.clusterlane-ls-result-item:hover{background:#f0f7ff;outline:0}.clusterlane-ls-result-title{display:block}.clusterlane-ai-post-meta{margin-top:10px}.clusterlane-ai-tax-pill{display:inline-block;padding:2px 9px;border-radius:12px;font-size:11px;font-weight:600;margin-right:4px;margin-top:4px}.clusterlane-ai-tax-pill--cat{background:#e0f2fe;color:#0369a1}.clusterlane-ai-tax-pill--tag{background:#f0fdf4;color:#166534}.clusterlane-ai-section-title{font-size:13px;font-weight:700;color:#1e293b;margin:0 0 14px;padding-bottom:8px;border-bottom:1px solid #f1f5f9}.clusterlane-ai-no-data{color:#94a3b8;font-size:13px;margin:0;font-style:italic}.clusterlane-ai-links-details{margin-top:8px;border:1px solid #f1f5f9;border-radius:6px;overflow:hidden}.clusterlane-ai-links-details+.clusterlane-ai-links-details{margin-top:6px}.clusterlane-ai-links-details>summary{cursor:pointer;padding:8px 12px;font-size:12.5px;font-weight:600;color:#334155;background:#f8fafc;display:flex;align-items:center;gap:8px;list-style:none;user-select:none}.clusterlane-ai-links-details>summary::-webkit-details-marker{display:none}.clusterlane-ai-links-details>summary::before{content:"▶";font-size:9px;color:#94a3b8;transition:transform .15s}.clusterlane-ai-links-details[open]>summary::before{transform:rotate(90deg)}.clusterlane-ai-link-list{margin:0;padding:8px 12px;list-style:none;max-height:200px;overflow-y:auto}.clusterlane-ai-link-list li+li{border-top:1px solid #f8fafc}.clusterlane-ai-link-list li{padding:4px 0;font-size:12.5px}.clusterlane-ai-link-list a{color:#2563eb;text-decoration:none}.clusterlane-ai-link-list a:hover{text-decoration:underline}.clusterlane-ai-count-badge{display:inline-flex;align-items:center;justify-content:center;background:#e2e8f0;color:#475569;border-radius:10px;font-size:11px;font-weight:700;padding:1px 6px;margin-left:4px}.clusterlane-ai-cluster-row{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid #f1f5f9;border-radius:6px;text-decoration:none;color:inherit;transition:background .1s;flex-wrap:nowrap;overflow:hidden}.clusterlane-ai-cluster-row:last-child{border-bottom:none}.clusterlane-ai-cluster-row:hover{background:#f8fafc}.clusterlane-ai-cluster-type{font-size:13px;flex-shrink:0}.clusterlane-ai-cluster-name{font-size:13px;font-weight:600;color:#1e293b;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-ai-cluster-health{font-size:12px;font-weight:700;flex-shrink:0}.clusterlane-ai-cluster-pct{font-size:11px;color:#94a3b8;flex-shrink:0}.clusterlane-ai-cluster-arrow{font-size:12px;color:#cbd5e1;flex-shrink:0;margin-left:2px}.clusterlane-ai-role-pill{display:inline-block;padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em}.clusterlane-ai-role-pill--pillar{background:#fdeadf;color:#b4491a}.clusterlane-ai-role-pill--support{background:#fdf4d4;color:#86600a}.clusterlane-ai-role-pill--light{background:#edf4e4;color:#4a6b2a}.clusterlane-ai-role-pill--orphan{background:#e4f0f8;color:#245f84}.clusterlane-ai-graph-card{margin-top:16px}.clusterlane-ai-graph-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}.clusterlane-ai-graph-wrap{position:relative;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;background:#fafbfc}#clusterlane-ai-ego-graph{width:100%;height:420px}.clusterlane-ai-graph-legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;font-size:11px;color:#64748b}.clusterlane-ai-graph-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(250,251,252,.8);font-size:13px;color:#64748b;pointer-events:none}.clusterlane-ai-graph-overlay.hidden{display:none}.clusterlane-ai-analysis-header{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:12px}.clusterlane-ai-analysis-title-row{display:flex;align-items:center;gap:10px}.clusterlane-ai-analysis-actions{display:flex;align-items:center;gap:8px}.clusterlane-ai-cache-note{font-size:11px;color:#94a3b8;font-weight:400;margin-left:4px}.clusterlane-ai-analysis-section{margin-top:20px;padding-top:18px;border-top:1px solid #e2e8f0}.clusterlane-ai-analysis-section:first-child{margin-top:0;padding-top:0;border-top:none}.clusterlane-ai-analysis-section-title{font-size:13px;font-weight:600;color:#1e293b;margin:0 0 10px}.clusterlane-ai-analysis-text{margin:0;font-size:13px;line-height:1.65;color:#334155}.clusterlane-ai-analysis-list{display:flex;flex-direction:column;gap:10px}.clusterlane-ai-analysis-item{display:grid;grid-template-columns:160px 1fr;gap:10px;align-items:baseline;background:#f8fafc;border-radius:6px;padding:10px 14px;border-left:3px solid #3b82f6}.clusterlane-ai-analysis-item--sister{border-left-color:#10b981;grid-template-columns:1fr}.clusterlane-ai-analysis-item--sister .clusterlane-ai-analysis-item-label{font-style:italic;color:#0f766e}.clusterlane-ai-analysis-item-label{font-size:12px;font-weight:600;color:#1e293b;line-height:1.4}.clusterlane-ai-analysis-item-text{font-size:12px;color:#475569;line-height:1.55}@media (max-width:640px){.clusterlane-ai-analysis-item{grid-template-columns:1fr}}.clusterlane-ai-recent-list{display:flex;flex-direction:column;gap:4px}.clusterlane-ai-recent-item{display:flex;align-items:center;gap:8px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px 12px;cursor:pointer;text-align:left;transition:background .12s,border-color .12s;width:100%;max-width:480px}.clusterlane-ai-recent-item:hover{background:#eff6ff;border-color:#bfdbfe}.clusterlane-ai-recent-icon{font-size:13px;flex-shrink:0;opacity:.6}.clusterlane-ai-recent-title{font-size:13px;color:#1e40af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-page-hero{background:linear-gradient(135deg,#0f172a 0,#1e3a5f 100%);border-radius:12px;padding:20px 28px;display:flex;align-items:center;gap:14px;margin-top:16px}.clusterlane-ci-hero-title h1,.clusterlane-page-hero h1{margin:0!important;color:#fff!important;font-size:1.6rem!important;font-weight:800!important;letter-spacing:-.01em!important;line-height:1.15!important}.clusterlane-page-hero-sub{color:rgba(255,255,255,.6);font-size:.8rem;margin-top:3px;display:block}.clusterlane-page-hero+*{margin-top:16px!important}.clusterlane-ci-hero{background:linear-gradient(135deg,#0f172a 0,#1e3a5f 100%);border-radius:12px;padding:24px 28px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:20px;margin-top:16px}.clusterlane-ci-hero-title{display:flex;align-items:center;gap:12px}.clusterlane-ci-hero-stats{display:flex;gap:28px;flex-wrap:wrap}.clusterlane-ci-stat{display:flex;flex-direction:column;align-items:center;gap:2px}.clusterlane-ci-stat-value{font-size:1.5rem;font-weight:700;color:#fff;line-height:1}.clusterlane-ci-stat-label{font-size:10px;color:rgba(255,255,255,.55);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.clusterlane-ci-cluster-row{display:flex;align-items:center;justify-content:space-between;padding:11px 14px;border-radius:8px;cursor:pointer;border:1px solid transparent;transition:background .1s,border-color .1s;gap:12px}.clusterlane-ci-cluster-row:hover{background:#f8fafc;border-color:#e2e8f0}.clusterlane-ci-cluster-row--active{background:#eff6ff;border-color:#bfdbfe}.clusterlane-ci-cluster-type{font-size:14px;flex-shrink:0}.clusterlane-ci-detail-header{background:linear-gradient(135deg,#1e293b 0,#334155 100%);padding:20px 24px 16px}.clusterlane-ci-detail-title-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.clusterlane-ci-detail-label{margin:0;font-size:1.1rem;color:#fff;font-weight:700}.clusterlane-ci-detail-close{background:rgba(255,255,255,.12);border:none;color:#fff;font-size:1.2rem;width:28px;height:28px;border-radius:50%;cursor:pointer;line-height:1;display:flex;align-items:center;justify-content:center;transition:background .12s}.clusterlane-ci-detail-close:hover{background:rgba(255,255,255,.25)}.clusterlane-ci-detail-stats{display:flex;gap:16px;flex-wrap:wrap}.clusterlane-ci-detail-stat{font-size:12px;color:rgba(255,255,255,.7)}.clusterlane-ci-detail-stat strong{color:#fff}.clusterlane-cluster-ai-stale-banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:#fefce8;border:1px solid #fde047;border-radius:6px;padding:8px 12px;margin-bottom:14px;font-size:12px;color:#854d0e;line-height:1.4}.clusterlane-cluster-ai-stale-regen{margin-left:auto;background:#fff;border:1px solid #fbbf24;border-radius:5px;color:#92400e;font-size:11px;font-weight:600;padding:3px 10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.clusterlane-cluster-ai-stale-regen:hover{background:#fef9c3}.clusterlane-onboarding-overlay{position:fixed;inset:0;z-index:999999;background:rgba(15,23,42,.75);display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}.clusterlane-onboarding-modal{background:#fff;border-radius:16px;width:100%;max-width:560px;max-height:90vh;overflow-y:auto;padding:40px 40px 28px;box-shadow:0 24px 80px rgba(0,0,0,.35);position:relative;display:flex;flex-direction:column}.clusterlane-ob-icon{font-size:3rem;text-align:center;margin-bottom:16px;line-height:1}.clusterlane-ob-title{font-size:1.25rem;font-weight:800;color:#0f172a;margin:0 0 12px;text-align:center;letter-spacing:-.01em}.clusterlane-ob-desc{font-size:14px;color:#475569;line-height:1.65;margin:0 0 10px;text-align:center}.clusterlane-ob-actions{display:flex;gap:10px;justify-content:center;margin-top:24px;flex-wrap:wrap}.clusterlane-ob-status{font-size:12px;color:#ef4444;text-align:center;min-height:18px;margin-top:8px}.clusterlane-ob-category-list{max-height:280px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:12px}.clusterlane-ob-category-item{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #f1f5f9;cursor:pointer;transition:background .1s}.clusterlane-ob-category-item:last-child{border-bottom:none}.clusterlane-ob-category-item:hover{background:#f8fafc}.clusterlane-ob-category-item input[type=checkbox]{width:16px;height:16px;flex-shrink:0;cursor:pointer;accent-color:#2271b1}.clusterlane-ob-cat-name{font-size:13px;font-weight:600;color:#1e293b;flex:1}.clusterlane-ob-cat-count{font-size:11px;color:#94a3b8;flex-shrink:0}.clusterlane-ob-scroll{max-height:56vh;overflow-y:auto;margin:4px -6px 0;padding:0 6px}.clusterlane-ob-scroll .clusterlane-ob-category-list{max-height:220px;margin-top:8px}.clusterlane-ob-sub{font-size:13px;font-weight:700;color:#0f172a;margin:16px 0 2px;text-transform:none}.clusterlane-ob-sub:first-child{margin-top:4px}.clusterlane-ob-hint{font-size:12px;color:#64748b;line-height:1.5;margin:0 0 4px}.clusterlane-ob-rendered{margin-top:12px;padding:10px 12px;border:1px solid #bfdbfe;background:#eff6ff;border-radius:8px}.clusterlane-ob-rendered label{display:flex;gap:8px;align-items:flex-start;font-size:13px;color:#1e293b;cursor:pointer;margin-bottom:4px}.clusterlane-ob-rendered input[type=checkbox]{margin-top:2px;accent-color:#2271b1}.clusterlane-ob-group{margin-top:12px}.clusterlane-ob-group[hidden],.clusterlane-ob-hub[hidden],.clusterlane-ob-no-groups[hidden],.clusterlane-ob-rendered[hidden]{display:none}.clusterlane-ob-group-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.clusterlane-ob-group-head>span{font-size:12px;font-weight:700;color:#334155}.clusterlane-ob-group-all{background:0 0;border:0;padding:0;color:#2271b1;font-size:12px;cursor:pointer;text-decoration:underline}.clusterlane-ob-group-all:hover{color:#135e96}.clusterlane-ob-no-groups{margin-top:10px}.clusterlane-rendered-rec{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:10px;background:#dbeafe;color:#1e40af;font-size:11px;font-weight:600}.clusterlane-rendered-log{margin-top:8px!important}.clusterlane-rendered-log--bad{color:#b45309!important}.clusterlane-ob-progress-wrap{margin-top:20px}.clusterlane-ob-progress-label{font-size:12px;color:#64748b;text-align:center;margin-top:8px}.clusterlane-ob-spinner-wrap{display:flex;justify-content:center;margin:28px 0}.clusterlane-ob-spinner-wrap .clusterlane-spinner{width:36px;height:36px;border-width:3px}.clusterlane-ob-steps-indicator{display:flex;justify-content:center;gap:8px;margin-top:28px;padding-top:20px;border-top:1px solid #f1f5f9}.clusterlane-ob-dot{width:8px;height:8px;border-radius:50%;background:#e2e8f0;transition:background .2s,transform .2s}.clusterlane-ob-dot--active{background:#2271b1;transform:scale(1.25)}.clusterlane-ob-dot--done{background:#93c5fd}.clusterlane-dash-nav-result{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 12px;text-decoration:none;color:#1e293b;font-size:13px;border-bottom:1px solid #f1f5f9;transition:background .1s}.clusterlane-dash-nav-result:last-child{border-bottom:none}.clusterlane-dash-nav-result:hover{background:#f8fafc}.clusterlane-dash-nav-result-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.clusterlane-dash-cluster-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px 0;border-bottom:1px solid rgba(0,0,0,.05);text-decoration:none;color:#334155;font-size:12px;transition:color .1s}.clusterlane-dash-cluster-row:last-child{border-bottom:none}.clusterlane-dash-cluster-row:hover{color:#2271b1}.clusterlane-dash-cluster-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.clusterlane-back-bar{font-size:12px;color:#64748b;margin-bottom:4px;padding:6px 0 2px}.clusterlane-back-bar-link{color:#2271b1;text-decoration:none;font-weight:500}.clusterlane-back-bar-link:hover{text-decoration:underline}.clusterlane-tms-empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:48px 24px;text-align:center;color:#475569}.clusterlane-tms-empty-icon{font-size:2.5rem;line-height:1;margin-bottom:4px;display:block}.clusterlane-tms-empty-state strong{font-size:1.1rem;color:#1e293b}.clusterlane-tms-empty-state p{font-size:13px;color:#64748b;margin:0;max-width:360px}.clusterlane-graph-infobar{display:flex;align-items:center;gap:0;flex-wrap:wrap;font-size:11px;color:#64748b;margin-bottom:8px}.clusterlane-graph-infobar>span{padding:3px 12px;position:relative;white-space:nowrap}.clusterlane-graph-infobar>span:first-child{padding-left:0}.clusterlane-graph-infobar>span:last-child::after{display:none}.clusterlane-graph-infobar>span::after{content:"";position:absolute;right:0;top:15%;height:70%;width:1px;background:linear-gradient(to bottom,transparent,#e2e8f0 25%,#e2e8f0 75%,transparent)}.clusterlane-graph-infobar strong{color:#1e293b}.clusterlane-graph-infobar-type{font-weight:600;color:#475569}.clusterlane-graph-infobar-arch{background:#f1f5f9;border:1px solid #e2e8f0;border-radius:999px;padding:2px 10px!important;color:#475569;font-weight:500;margin:0 2px}.clusterlane-graph-infobar>.clusterlane-gib-arch{display:inline-flex;align-items:center;gap:2px}.clusterlane-graph-infobar>.clusterlane-gib-arch{padding-top:0;padding-bottom:0}.clusterlane-ci-detail-header .clusterlane-graph-infobar>.clusterlane-gib-arch{padding-left:0}.clusterlane-ci-detail-header .clusterlane-graph-infobar,.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar{color:rgba(255,255,255,.55);margin-bottom:0}.clusterlane-ci-detail-header .clusterlane-graph-infobar>span::after,.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar>span::after{background:linear-gradient(to bottom,transparent,rgba(255,255,255,.2) 25%,rgba(255,255,255,.2) 75%,transparent)}.clusterlane-ci-detail-header .clusterlane-graph-infobar strong,.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar strong{color:rgba(255,255,255,.9)}.clusterlane-ci-detail-header .clusterlane-graph-infobar-type,.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar-type{color:rgba(255,255,255,.8)}.clusterlane-ci-detail-header .clusterlane-graph-infobar-arch,.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar-arch{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)!important;color:rgba(255,255,255,.8)}.clusterlane-ci-graph-modal-overlay{position:fixed;inset:0;z-index:99999;background:rgba(15,23,42,.7);display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box}.clusterlane-ci-graph-modal-inner{background:#fff;border-radius:12px;width:100%;max-width:1200px;height:85vh;max-height:860px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.3)}.clusterlane-ci-graph-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:linear-gradient(135deg,#0f172a 0,#1e3a5f 100%);flex-shrink:0}.clusterlane-ci-graph-modal-title{font-size:1rem;font-weight:700;color:#fff;letter-spacing:-.01em}.clusterlane-ci-graph-modal-body{display:flex;flex:1;min-height:0;overflow:hidden}.clusterlane-ci-graph-modal-canvas{flex:1;min-width:0;min-height:0;background:#fafbfc}.clusterlane-ci-graph-modal-right{width:300px;flex-shrink:0;padding:16px;border-left:1px solid #e2e8f0;overflow-y:auto;background:#f8fafc}.clusterlane-np{font-size:13px;color:#1e293b}.clusterlane-np__empty{display:flex;flex-direction:column;align-items:center;gap:8px;margin:48px 8px 0;color:#94a3b8;font-size:12.5px;line-height:1.5;text-align:center}.clusterlane-np__empty .clusterlane-g{width:28px;height:28px;opacity:.7}.clusterlane-np__img{display:block;width:100%;height:120px;object-fit:cover;border-radius:8px;margin-bottom:12px;background:#e2e8f0}.clusterlane-np__role{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;border:1px solid;border-radius:999px;padding:2px 8px}.clusterlane-np__title{margin:6px 0 12px;font-size:15px;line-height:1.35;font-weight:700;color:#0f172a;overflow-wrap:anywhere}.clusterlane-np__stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:6px;margin-bottom:12px}.clusterlane-np__stat{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 4px 7px;text-align:center}.clusterlane-np__stat b{display:block;font-size:18px;line-height:1.2;font-weight:700;color:#475569;font-variant-numeric:tabular-nums}.clusterlane-np__stat b.is-good{color:#15803d}.clusterlane-np__stat b.is-ok{color:#b45309}.clusterlane-np__stat b.is-low{color:#94a3b8}.clusterlane-np__stat b.is-bad{color:#dc2626}.clusterlane-np__stat span{display:block;font-size:10.5px;color:#64748b;margin-top:1px}.clusterlane-np__actions{display:grid;gap:6px;margin-bottom:16px}.clusterlane-np__btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:7px 10px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;color:#1e293b;font-size:12.5px;font-weight:500;text-decoration:none}.clusterlane-np__btn:focus,.clusterlane-np__btn:hover{border-color:#cbd5e1;background:#f1f5f9;color:#0f172a}.clusterlane-np__btn--primary{background:#2563eb;border-color:#2563eb;color:#fff}.clusterlane-np__btn--primary:focus,.clusterlane-np__btn--primary:hover{background:#1d4ed8;border-color:#1d4ed8;color:#fff}.clusterlane-np__btn .clusterlane-g{width:14px;height:14px}.clusterlane-np__sec{margin-top:14px}.clusterlane-np__h{display:flex;align-items:center;gap:6px;margin:0 0 6px;font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#64748b}.clusterlane-np__n{font-size:10.5px;font-weight:600;letter-spacing:0;color:#475569;background:#e2e8f0;border-radius:999px;padding:0 7px;line-height:17px}.clusterlane-np__list{list-style:none;margin:0;padding:0;background:#fff;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden}.clusterlane-np__row{display:flex;align-items:center;margin:0;border-top:1px solid #f1f5f9}.clusterlane-np__row:first-child{border-top:0}.clusterlane-np__row:hover{background:#f8fafc}.clusterlane-np__item{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:7px 4px 7px 10px;border:0;background:0 0;text-align:left;font:inherit;font-size:12.5px;line-height:1.35;color:#1e293b;cursor:pointer}.clusterlane-np__item:focus-visible .clusterlane-np__name,.clusterlane-np__item:hover .clusterlane-np__name{color:#2563eb}.clusterlane-np__item:focus-visible{outline:2px solid #2563eb;outline-offset:-2px;border-radius:6px}.clusterlane-np__dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.clusterlane-np__name{flex:1;min-width:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere}.clusterlane-np__both{flex-shrink:0;font-size:12px;line-height:1;color:#2563eb}.clusterlane-np__open{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:28px;height:28px;margin-right:4px;border-radius:6px;color:#94a3b8;opacity:0}.clusterlane-np__open:focus,.clusterlane-np__row:hover .clusterlane-np__open{opacity:1}.clusterlane-np__open:focus,.clusterlane-np__open:hover{color:#2563eb;background:#eff6ff}.clusterlane-np__open .clusterlane-g{width:14px;height:14px}@media (hover:none){.clusterlane-np__open{opacity:1}}.clusterlane-np__none{margin:0;padding:9px 10px;font-size:12px;color:#94a3b8;background:#fff;border:1px dashed #e2e8f0;border-radius:8px}.clusterlane-np__key{margin:12px 0 0;font-size:11px;color:#94a3b8}.clusterlane-np__key .clusterlane-np__both{font-size:11px}@media (max-width:782px){.clusterlane-np__img{display:none}.clusterlane-np__empty{margin-top:0;flex-direction:row;justify-content:center}}.clusterlane-cluster-ai-card{border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.05)}.clusterlane-cluster-ai-card-header{display:flex;align-items:center;gap:8px;padding:10px 16px;background:linear-gradient(135deg,#1e293b 0,#334155 100%)}.clusterlane-cluster-ai-card-title{font-size:13px;font-weight:600;color:#fff}.clusterlane-cluster-ai-card-body{padding:16px}.clusterlane-cluster-ai-section-heading{margin:0 0 6px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}.clusterlane-tip{position:relative;display:inline-flex;align-items:center;vertical-align:middle;margin-left:3px;cursor:help}.clusterlane-tip-icon{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#e2e8f0;color:#64748b;font-size:9px;font-weight:700;font-style:normal;line-height:1;transition:background .12s,color .12s;user-select:none}.clusterlane-tip:focus .clusterlane-tip-icon,.clusterlane-tip:hover .clusterlane-tip-icon{background:#3b82f6;color:#fff}.clusterlane-help-steps{display:flex;flex-direction:column;gap:0}.clusterlane-help-step{display:flex;gap:20px;align-items:flex-start;padding:20px 0;border-bottom:1px solid #f1f5f9}.clusterlane-help-step:last-child{border-bottom:none;padding-bottom:0}.clusterlane-help-step:first-child{padding-top:0}.clusterlane-help-step-num{flex-shrink:0;width:32px;height:32px;border-radius:50%;background:#16223a;color:#fff;font-size:14px;font-weight:700;display:flex;align-items:center;justify-content:center;margin-top:2px}.clusterlane-help-step-body h3{margin:0 0 6px;font-size:14px;font-weight:700;color:#1e293b}.clusterlane-help-step-body p{margin:0;font-size:13px;color:#475569;line-height:1.6}.clusterlane-help-reference{padding:0!important;overflow:hidden}.clusterlane-help-reference-summary{display:flex;align-items:center;gap:10px;padding:16px 24px;cursor:pointer;user-select:none;list-style:none}.clusterlane-help-reference-summary::-webkit-details-marker{display:none}.clusterlane-help-reference-summary::after{content:"›";font-size:16px;color:#94a3b8;transition:transform .15s;margin-left:auto}details[open] .clusterlane-help-reference-summary::after{transform:rotate(90deg)}.clusterlane-help-reference-body{padding:0 28px 24px;border-top:1px solid #f1f5f9}.clusterlane-help-term{display:grid;grid-template-columns:180px 1fr;gap:8px 16px;padding:10px 0;border-bottom:1px solid #f8fafc;align-items:baseline}.clusterlane-help-term:last-child{border-bottom:none}.clusterlane-help-term dt{font-size:12px;font-weight:600;color:#1e293b}.clusterlane-help-term dd{margin:0;font-size:12px;color:#475569;line-height:1.55}@media (max-width:600px){.clusterlane-help-term{grid-template-columns:1fr}.clusterlane-help-step{flex-direction:column;gap:10px}}.clusterlane-block-notice{background:#fffbeb;border:1px solid #f59e0b;border-left:4px solid #f59e0b;border-radius:4px;padding:10px 14px;margin-bottom:14px;font-size:.82rem;line-height:1.5;color:#78350f}.clusterlane-block-notice strong{color:#92400e}.clusterlane-block-notice--inline{margin:8px 0 10px;padding:8px 12px;font-size:.78rem}.vis-tooltip{background:0 0!important;border:none!important;box-shadow:none!important;padding:0!important;border-radius:0!important}.clusterlane-improve-panel{margin-bottom:28px;border-radius:14px;overflow:hidden;background:linear-gradient(180deg,#f0f7ff 0,#f8fafc 140px);border:1px solid #d6e4f5;box-shadow:0 4px 20px -8px rgba(34,113,177,.25)}.clusterlane-improve-panel-header{padding:16px 20px;background:linear-gradient(135deg,#1e2535 0,#2a3550 100%);color:#fff}.clusterlane-improve-panel-title{display:flex;align-items:center;gap:13px}.clusterlane-improve-panel-icon{font-size:22px;line-height:1;flex-shrink:0;filter:drop-shadow(0 2px 4px rgba(0,0,0,.3))}.clusterlane-improve-panel-heading{font-size:15px;font-weight:700;letter-spacing:.01em;color:#fff}.clusterlane-improve-panel-sub{font-size:12px;color:#b6c6de;margin-top:2px;line-height:1.45}.clusterlane-improve-panel-body{padding:16px 16px 14px;display:flex;flex-direction:column;gap:10px}.clusterlane-improve-block{border:1px solid #e2e8f0;border-radius:10px;margin-bottom:0;overflow:hidden;background:#fff;box-shadow:0 1px 3px rgba(15,23,41,.05);transition:box-shadow .15s,transform .15s}.clusterlane-improve-block:hover{box-shadow:0 3px 10px rgba(15,23,41,.09)}.clusterlane-improve-block-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;user-select:none;background:#fff;border-bottom:1px solid transparent;transition:background .15s}.clusterlane-improve-block-header:hover{background:#f8fafc}.clusterlane-improve-block-header.is-open{border-bottom-color:#eef2f7}.clusterlane-improve-block-label{display:flex;align-items:center;gap:9px;font-size:13.5px;font-weight:600;color:#1e293b}.clusterlane-improve-block-icon{font-size:17px;line-height:1;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;background:#f1f5f9;border-radius:8px;flex-shrink:0}.clusterlane-improve-block-meta{display:flex;align-items:center;gap:8px}.clusterlane-improve-count{font-size:11px;font-weight:700;padding:3px 10px;border-radius:999px}.clusterlane-improve-count--gap{background:#fef9c3;color:#854d0e}.clusterlane-improve-chevron{font-size:13px;color:#94a3b8;transition:transform .2s;display:inline-block}.clusterlane-improve-block-header.is-open .clusterlane-improve-chevron{transform:rotate(180deg)}.clusterlane-improve-block-body{display:none;padding:12px 16px 14px}.clusterlane-improve-block-body.is-open{display:block}.clusterlane-improve-loading{font-size:12px;color:#94a3b8;padding:6px 0}.clusterlane-gap-item{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid #f1f5f9}.clusterlane-gap-item:last-child{border-bottom:none;padding-bottom:0}.clusterlane-gap-item-text{flex:1;font-size:12px;color:#1e293b;line-height:1.5}.clusterlane-gap-item-text small{display:block;color:#64748b;font-size:11px;margin-top:2px}.clusterlane-improve-count--tms{background:#dcfce7;color:#166534}.clusterlane-improve-count--ailink{background:#ede9fe;color:#5b21b6}.clusterlane-ls-field-hint{margin:4px 0 10px;font-size:.72rem;color:#64748b;line-height:1.4}.clusterlane-ls-field-hint code{background:#eef2ff;color:#4338ca;padding:1px 5px;border-radius:3px;font-size:.68rem;font-weight:600}.clusterlane-ls-context-card{background:linear-gradient(135deg,#1e2535 0,#2a3550 100%);border-radius:10px;padding:14px 16px;color:#e2e8f0}.clusterlane-ls-context-flow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.clusterlane-ls-context-node{font-size:.82rem;font-weight:600;padding:5px 11px;border-radius:6px;line-height:1.3}.clusterlane-ls-context-source{background:rgba(255,255,255,.12);color:#fff}.clusterlane-ls-context-target{background:#2271b1;color:#fff}.clusterlane-ls-context-arrow{font-size:1.1rem;color:#94a3b8;flex-shrink:0}.clusterlane-ls-context-why{margin-top:10px;font-size:.78rem;line-height:1.5;color:#cbd5e1}.clusterlane-ls-context-why strong{color:#fff}.clusterlane-ls-variant-tabs{display:flex;gap:6px;margin-bottom:4px;min-width:0}.clusterlane-ls-variant-tab{flex:1;min-width:0;padding:7px 10px;font-size:.78rem;font-weight:600;color:#64748b;background:#fff;border:1px solid #e2e8f0;border-radius:7px;cursor:pointer;transition:all .15s;box-sizing:border-box}.clusterlane-ls-variant-tab:hover{border-color:#cbd5e1;color:#334155}.clusterlane-ls-variant-tab.is-active{background:#2271b1;border-color:#2271b1;color:#fff}.clusterlane-improve-cta{display:flex;align-items:center;gap:14px;padding:4px 2px}.clusterlane-improve-cta-text{flex:1;margin:0;font-size:12.5px;color:#475569;line-height:1.5}.clusterlane-improve-cta .clusterlane-action-btn{flex-shrink:0}.clusterlane-ailink-arrow{color:#94a3b8;font-weight:600}#clusterlane-dash-graph-card{margin-top:20px}.clusterlane-dash-graph-details{padding:0}.clusterlane-dash-graph-ci-btn{background:#16223a;color:#fff!important;border-color:#16223a;font-size:13px;font-weight:600;padding:6px 16px;border-radius:8px;letter-spacing:.01em;transition:background 120ms,border-color 120ms}.clusterlane-dash-graph-ci-btn:hover{background:#1e3a5f;border-color:#1e3a5f;color:#fff!important;text-decoration:none}.clusterlane-dash-graph-details>summary{list-style:none;cursor:pointer}.clusterlane-dash-graph-details>summary::-webkit-details-marker{display:none}.clusterlane-dash-graph-details>div{padding:0 24px 20px}.clusterlane-dash-graph-controls{display:flex;align-items:center;gap:8px}.clusterlane-dash-graph-controls label{font-size:12px;color:#64748b;font-weight:500}#clusterlane-dash-graph-select{font-size:12px;font-weight:600;color:#1e293b;background:#fff;border:1.5px solid #cbd5e1;border-radius:6px;padding:4px 30px 4px 10px;height:34px;min-width:200px;max-width:320px;cursor:pointer}#clusterlane-dash-graph-select:focus{outline:0;border-color:#2271b1;box-shadow:0 0 0 2px rgba(34,113,177,.15)}#clusterlane-dash-graph-container{height:480px;position:relative;border:1px solid #e2e8f0;border-radius:6px;background:#f8fafc;overflow:hidden}#clusterlane-dash-graph-container .vis-network{border-radius:6px}.clusterlane-dash-graph-hint,.clusterlane-graph-zoom-hint{position:absolute;right:10px;bottom:10px;z-index:5;font-size:11px;color:#64748b;background:rgba(255,255,255,.9);border:1px solid #e2e8f0;border-radius:999px;padding:4px 10px;pointer-events:none;transition:opacity .2s}.is-zoom-active .clusterlane-dash-graph-hint,.is-zoom-active .clusterlane-graph-zoom-hint{opacity:0}#clusterlane-ai-ego-graph.is-zoom-active,#clusterlane-ci-detail-graph.is-zoom-active,#clusterlane-ci-flow-graph.is-zoom-active,#clusterlane-dash-graph-container.is-zoom-active{outline:2px solid rgba(34,113,177,.35);outline-offset:-2px}#clusterlane-ai-ego-graph,#clusterlane-ci-detail-graph,#clusterlane-ci-flow-graph{position:relative}#clusterlane-dash-graph-notice{padding:32px 20px;text-align:center;color:#64748b;font-size:13px;line-height:1.6}@keyframes clusterlaneGraphFlash{0%{box-shadow:0 0 0 0 rgba(34,113,177,.45)}100%{box-shadow:0 0 0 12px rgba(34,113,177,0)}}.clusterlane-dash-graph-card--flash{animation:clusterlaneGraphFlash .9s ease-out}.clusterlane-dash-cluster-row--graph{cursor:pointer}.clusterlane-dash-cluster-row--graph:hover .clusterlane-dash-cluster-label{text-decoration:underline}.clusterlane-site-analysis-card{margin-top:20px}details.clusterlane-collapse{padding:0!important;margin-top:16px}.clusterlane-collapse-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:16px 24px;cursor:pointer;list-style:none;user-select:none;border-radius:14px}.clusterlane-collapse-head::-webkit-details-marker{display:none}.clusterlane-collapse-head::before{content:"▸";font-size:.9rem;color:#94a3b8;transition:transform 120ms;flex-shrink:0;width:12px}details.clusterlane-collapse[open]>.clusterlane-collapse-head::before{transform:rotate(90deg)}details.clusterlane-collapse[open]>.clusterlane-collapse-head{border-bottom:1px solid #f1f5f9;border-radius:14px 14px 0 0}.clusterlane-collapse-head:hover{background:#fafbfc}.clusterlane-collapse-head .clusterlane-ai-section-title{margin:0}.clusterlane-collapse-count{font-size:.8rem;color:#94a3b8}.clusterlane-collapse-sub{font-size:11px;color:#94a3b8;margin-left:auto}.clusterlane-collapse-body{padding:16px 24px 20px}.clusterlane-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 10px;margin-bottom:14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px}.clusterlane-toolbar-label{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#94a3b8}.clusterlane-toolbar-sep{width:1px;height:22px;background:#e2e8f0}.clusterlane-toolbar-search{flex:1 1 220px;max-width:360px}.clusterlane-toolbar .clusterlane-search-wrap .clusterlane-search-input{height:32px;box-shadow:none}.clusterlane-toolbar .clusterlane-tms-select{height:32px!important;padding-top:0!important;padding-bottom:0!important}.clusterlane-seg{display:inline-flex;gap:0;background:#fff;border:1.5px solid #cbd5e1;border-radius:6px;overflow:hidden;height:32px;box-sizing:border-box}.clusterlane-seg .clusterlane-seg-btn{border:0;border-right:1px solid #e2e8f0;border-radius:0;background:#fff;color:#475569;font-size:12px;font-weight:500;padding:0 12px;height:100%;cursor:pointer;transition:background .1s,color .1s}.clusterlane-seg .clusterlane-seg-btn:last-child{border-right:0}.clusterlane-seg .clusterlane-seg-btn:hover{background:#f1f5f9;color:#1e293b}.clusterlane-seg .clusterlane-seg-btn.active,.clusterlane-seg .clusterlane-seg-btn.is-active{background:#16223a;color:#fff;font-weight:600}.clusterlane-gap-item-actions{display:flex;gap:6px;flex-shrink:0;align-items:center}#clusterlane-sticky-bar{position:sticky;top:32px;z-index:900;background:#fff;border-bottom:1px solid #e2e8f0;box-shadow:0 2px 8px rgba(0,0,0,.06);padding:0 16px;display:flex;align-items:center;gap:12px;height:48px;margin:0 -20px 20px}#clusterlane-sticky-search-results{position:absolute;top:calc(100% + 4px);left:0;right:0;background:#fff;border:1.5px solid #e2e8f0;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.1);z-index:9999;max-height:320px;overflow-y:auto}@media (max-width:782px){#clusterlane-sticky-bar{top:46px;gap:8px;padding:0 10px}}.clusterlane-stickybar{--sb-ink:#16223a;--sb-ink-2:#1c2b47;--sb-line:rgba(255,255,255,.08);--sb-text:#9fb0c8;--sb-text-hi:#f1f5f9;--sb-muted:#7c8ea8;--sb-node:#60a5fa;position:sticky;top:32px;z-index:900;margin:0 -20px 10px;padding:8px 20px 12px;background:#f0f0f1}.clusterlane-stickybar__inner{position:relative;display:flex;flex-wrap:wrap;align-items:center;background:var(--sb-ink);border-radius:14px;box-shadow:0 6px 18px rgba(15,23,42,.16);transition:box-shadow .2s ease}.clusterlane-stickybar.is-stuck .clusterlane-stickybar__inner{box-shadow:0 10px 28px rgba(15,23,42,.28)}.clusterlane-stickybar__inner>.clusterlane-stickybar__nav{flex:0 0 100%;width:100%}.clusterlane-stickybar__nav{display:flex;align-items:stretch;height:46px;padding:0 14px 0 8px;border-bottom:1px solid var(--sb-line);overflow:hidden}.clusterlane-stickybar__brand{display:inline-flex;align-items:center;padding:0 10px 0 4px;margin-right:6px;border-right:1px solid var(--sb-line);text-decoration:none;flex-shrink:0}.clusterlane-stickybar__brand-mark{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px}.clusterlane-stickybar__brand-mark .clusterlane-brand-logo,.clusterlane-stickybar__brand-mark .clusterlane-brand-mark{display:block}.clusterlane-hero-mark{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;flex-shrink:0}.clusterlane-hero-mark .clusterlane-brand-logo,.clusterlane-hero-mark .clusterlane-brand-mark{display:block}.clusterlane-brand-logo{object-fit:contain;border-radius:6px}.clusterlane-stickybar__brand:focus-visible{outline:2px solid var(--sb-node);outline-offset:2px;border-radius:8px}.clusterlane-stickybar__tab{position:relative;display:inline-flex;align-items:center;gap:6px;padding:0 12px;margin:7px 1px;border-radius:8px;font-size:12.5px;font-weight:500;color:var(--sb-text);text-decoration:none;white-space:nowrap;transition:color 120ms,background 120ms}.clusterlane-stickybar__tab .dashicons{font-size:15px;width:15px;height:15px;opacity:.7}.clusterlane-stickybar__tab:focus,.clusterlane-stickybar__tab:hover{color:var(--sb-text-hi);background:rgba(255,255,255,.05);text-decoration:none;box-shadow:none}.clusterlane-stickybar__tab:focus-visible{outline:2px solid var(--sb-node);outline-offset:-2px}.clusterlane-stickybar__tab--active,.clusterlane-stickybar__tab--active:hover{color:var(--sb-text-hi);font-weight:600;background:rgba(96,165,250,.12)}.clusterlane-stickybar__tab--active .dashicons{opacity:1;color:var(--sb-node)}.clusterlane-stickybar__tab--active::after{content:"";position:absolute;left:50%;bottom:-8px;width:7px;height:7px;margin-left:-3.5px;border-radius:50%;background:var(--sb-node);box-shadow:0 0 0 3px rgba(96,165,250,.2),0 0 12px 2px rgba(96,165,250,.55)}.clusterlane-stickybar__tab--group{margin-left:13px}.clusterlane-stickybar__tab--group::before{content:"";position:absolute;left:-8px;top:8px;bottom:8px;width:1px;background:var(--sb-line)}.clusterlane-stickybar__more{display:flex;align-items:stretch;margin-left:auto;padding-left:6px;margin-right:6px}.clusterlane-stickybar__tab-label{display:inline-flex;flex-direction:column}.clusterlane-stickybar__tab-label::after{content:attr(data-label);height:0;overflow:hidden;visibility:hidden;font-weight:600;pointer-events:none;user-select:none}.clusterlane-stickybar__more-btn.clusterlane-stickybar__tab--active-in::after{content:"";position:absolute;left:50%;bottom:-8px;width:7px;height:7px;margin-left:-3.5px;border-radius:50%;background:var(--sb-node);box-shadow:0 0 0 3px rgba(96,165,250,.2),0 0 12px 2px rgba(96,165,250,.55)}.clusterlane-stickybar__more[hidden]{display:none}.clusterlane-stickybar__more-btn{border:0;background:0 0;cursor:pointer;font-family:inherit}.clusterlane-stickybar__more-btn.clusterlane-stickybar__tab--active-in{color:var(--sb-text-hi);background:rgba(96,165,250,.12)}.clusterlane-stickybar__more-menu{position:fixed;z-index:10000;min-width:210px;display:flex;flex-direction:column;padding:6px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 14px 34px rgba(15,23,42,.22)}.clusterlane-stickybar__more-menu[hidden]{display:none}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab{margin:0;padding:9px 10px;color:#1e293b;flex-direction:row;justify-content:flex-start;gap:8px;max-width:none}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab:focus,.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab:hover{color:#0f172a;background:#f1f5f9}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab .dashicons{opacity:.6}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab--active{color:#1d4ed8;background:#eff6ff}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab::after,.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab::before{display:none}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab--group{margin-left:0;margin-top:5px;border-top:1px solid #f1f5f9;padding-top:12px}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab-label::after{display:none}.clusterlane-stickybar__more-menu .clusterlane-stickybar__tab-label{display:inline!important;font-size:13px!important;white-space:nowrap;text-align:left;line-height:1.3}.clusterlane-stickybar__search-group{display:flex;align-items:center;gap:8px;flex:1 1 auto;height:48px;padding:0 14px;background:var(--sb-ink-2);border-bottom-left-radius:14px}.clusterlane-stickybar__actions{display:flex;align-items:center;gap:6px;height:48px;padding:0 14px 0 0;margin-left:auto;flex-shrink:0;background:var(--sb-ink-2);border-bottom-right-radius:14px}.clusterlane-stickybar__inner{background:linear-gradient(to bottom,var(--sb-ink) 46px,var(--sb-ink-2) 46px)}.clusterlane-stickybar__search-wrap{position:relative;display:flex;align-items:center}.clusterlane-stickybar__search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--sb-muted);font-size:14px;width:14px;height:14px;pointer-events:none}#clusterlane-stickybar .clusterlane-stickybar__cluster-select,#clusterlane-stickybar .clusterlane-stickybar__search-input,#clusterlane-stickybar input[type=search].clusterlane-stickybar__search-input,#clusterlane-stickybar select.clusterlane-stickybar__cluster-select{height:32px!important;min-height:0!important;border:1px solid rgba(255,255,255,.1)!important;border-radius:8px!important;font-size:13px!important;color:var(--sb-text-hi)!important;background-color:rgba(255,255,255,.06)!important;box-shadow:none!important;box-sizing:border-box!important;margin:0!important;transition:border-color 120ms,background-color 120ms,box-shadow 120ms}#clusterlane-stickybar .clusterlane-stickybar__search-input,#clusterlane-stickybar input[type=search].clusterlane-stickybar__search-input{width:230px!important;padding:0 10px 0 32px!important;line-height:30px!important}#clusterlane-stickybar .clusterlane-stickybar__search-input::placeholder{color:var(--sb-muted);opacity:1}#clusterlane-stickybar .clusterlane-stickybar__cluster-select,#clusterlane-stickybar select.clusterlane-stickybar__cluster-select{min-width:210px!important;padding:0 30px 0 32px!important;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237c8ea8'/%3E%3C/svg%3E")!important;background-repeat:no-repeat!important;background-position:right 11px center!important;background-size:10px 6px!important;line-height:30px!important;appearance:none!important;-webkit-appearance:none!important;cursor:pointer!important}#clusterlane-stickybar .clusterlane-stickybar__cluster-select option{color:#1e293b;background:#fff}#clusterlane-stickybar .clusterlane-stickybar__cluster-select:hover,#clusterlane-stickybar .clusterlane-stickybar__search-input:hover{border-color:rgba(255,255,255,.2)!important}#clusterlane-stickybar .clusterlane-stickybar__cluster-select:focus,#clusterlane-stickybar .clusterlane-stickybar__search-input:focus,#clusterlane-stickybar input[type=search].clusterlane-stickybar__search-input:focus,#clusterlane-stickybar select.clusterlane-stickybar__cluster-select:focus{outline:0!important;border-color:var(--sb-node)!important;background-color:rgba(255,255,255,.1)!important;box-shadow:0 0 0 3px rgba(96,165,250,.22)!important}.clusterlane-stickybar__dropdown{position:absolute;top:calc(100% + 8px);left:0;min-width:100%;width:340px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 14px 34px rgba(15,23,42,.22);z-index:9999;max-height:340px;overflow-y:auto;list-style:none;margin:0;padding:4px 0}.clusterlane-stickybar__dropdown li{display:flex;align-items:center;gap:10px;padding:8px 14px;cursor:pointer;font-size:13px;color:#1e293b;border-bottom:1px solid #f1f5f9;transition:background .1s}.clusterlane-stickybar__dropdown li:last-child{border-bottom:0}.clusterlane-stickybar__dropdown li:hover{background:#f1f5f9}.clusterlane-stickybar__action-btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 13px;border:1px solid rgba(255,255,255,.12);border-radius:8px;background:0 0;color:var(--sb-text);font-size:12.5px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background 120ms,color 120ms,border-color 120ms}.clusterlane-stickybar__action-btn .dashicons{font-size:14px;width:14px;height:14px}.clusterlane-stickybar__action-btn:hover{background:rgba(255,255,255,.07);color:var(--sb-text-hi);border-color:rgba(255,255,255,.22)}.clusterlane-stickybar__action-btn:focus-visible{outline:2px solid var(--sb-node);outline-offset:2px}.clusterlane-stickybar__action-btn:disabled{opacity:.55;cursor:progress}.clusterlane-stickybar__action-btn--primary{background:#fff;border-color:#fff;color:var(--sb-ink);font-weight:600}.clusterlane-stickybar__action-btn--primary:hover{background:#e2e8f0;border-color:#e2e8f0;color:var(--sb-ink)}.clusterlane-stickybar .clusterlane-pro-badge--tab{box-shadow:0 0 0 1px rgba(255,255,255,.15)}.clusterlane-stickybar__toast{position:absolute;bottom:-30px;left:50%;transform:translateX(-50%);background:var(--sb-ink);color:var(--sb-text-hi);font-size:12px;padding:7px 14px;border-radius:8px;box-shadow:0 8px 20px rgba(15,23,42,.25);white-space:nowrap;pointer-events:none;z-index:1000}@media (prefers-reduced-motion:reduce){.clusterlane-stickybar,.clusterlane-stickybar__action-btn,.clusterlane-stickybar__tab{transition:none}}@media (max-width:782px){.clusterlane-stickybar{position:relative;top:0;margin:0 -10px 8px;padding:6px 10px 10px}.clusterlane-stickybar__inner{border-radius:12px;background:linear-gradient(to bottom,var(--sb-ink) 54px,var(--sb-ink-2) 54px)}.clusterlane-stickybar__nav{height:54px;padding:0 4px}.clusterlane-stickybar__brand{padding:0 6px 0 2px;margin-right:2px}.clusterlane-stickybar__tab{flex-direction:column;justify-content:center;gap:3px;padding:0 6px;margin:5px 0;max-width:76px}.clusterlane-stickybar__tab-label{font-size:10px;line-height:1.15;white-space:normal;text-align:center}.clusterlane-stickybar__tab--group{margin-left:9px}.clusterlane-stickybar__tab--group::before{left:-5px;top:12px;bottom:12px}.clusterlane-stickybar__nav .clusterlane-pro-badge--tab{display:none}.clusterlane-stickybar__tab--active::after{bottom:-6px}.clusterlane-stickybar__search-group{padding:0 10px;gap:6px;flex:1 1 auto;border-bottom-left-radius:12px}.clusterlane-stickybar__search-wrap{flex:1 1 auto;min-width:0}#clusterlane-stickybar .clusterlane-stickybar__search-input,#clusterlane-stickybar input[type=search].clusterlane-stickybar__search-input{width:100%!important}#clusterlane-stickybar .clusterlane-stickybar__cluster-select,#clusterlane-stickybar select.clusterlane-stickybar__cluster-select{min-width:0!important;width:100%!important}.clusterlane-stickybar__actions{padding:0 10px 0 0;border-bottom-right-radius:12px}.clusterlane-stickybar__action-btn--primary .clusterlane-stickybar__action-label{display:inline}.clusterlane-stickybar__action-btn{padding:0 9px;min-width:34px;justify-content:center}}@media print{#adminmenuback,#adminmenuwrap,#clusterlane-sa-print,#clusterlane-site-analysis-regen,#wpadminbar,#wpfooter,.clusterlane-action-btn,.clusterlane-sa-ai-badge,.clusterlane-sa-btn-wrap,.clusterlane-stickybar,.error,.notice,.update-nag{display:none!important}body,html{margin:0!important;padding:0!important;background:#fff!important}#wpbody,#wpbody-content,#wpcontent{margin:0!important;padding:0!important;float:none!important}.wrap{margin:0!important;padding:12mm 14mm!important}#clusterlane-site-analysis-card{display:block!important}#clusterlane-site-analysis-card>summary{display:none!important}#clusterlane-site-analysis-body{display:block!important}.clusterlane-sa-action,.clusterlane-sa-opp,.clusterlane-sa-strength{break-inside:avoid}.clusterlane-sa-opp-grid,.clusterlane-sa-strength-grid{grid-template-columns:repeat(3,1fr)!important}.clusterlane-sa-action-list{grid-template-columns:1fr!important}.clusterlane-site-analysis-header{margin-bottom:6mm}.clusterlane-site-analysis-title{font-size:18pt!important}#clusterlane-site-analysis-result::after{content:"Generated by Clusterlane · " attr(data-generated);display:block;margin-top:8mm;font-size:9pt;color:#94a3b8;border-top:1px solid #e2e8f0;padding-top:4mm}}.clusterlane-support-panel{margin-top:24px;background:#fff;border:1px solid #e2e8f0;border-radius:14px;overflow:hidden;box-shadow:0 4px 18px rgba(15,23,42,.06)}.clusterlane-support-header{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:14px;padding:18px 22px;background:linear-gradient(135deg,#1e2535 0,#2a3550 100%);color:#fff}.clusterlane-support-title{display:flex;align-items:center;gap:13px}.clusterlane-support-icon{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:10px;background:rgba(255,255,255,.1);font-size:20px}.clusterlane-support-title h2{margin:0;color:#fff;font-size:16px;font-weight:700}.clusterlane-support-title p{margin:2px 0 0;color:#94a3b8;font-size:12px}.clusterlane-support-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.clusterlane-support-badge{padding:4px 10px;border-radius:999px;font-size:12px;font-weight:600}.clusterlane-support-badge--ok{background:rgba(16,185,129,.18);color:#6ee7b7}.clusterlane-support-badge--warn{background:rgba(245,158,11,.18);color:#fcd34d}.clusterlane-support-btn{height:34px;padding:0 14px;border-radius:8px;cursor:pointer;border:1px solid rgba(255,255,255,.25);background:0 0;color:#fff;font-size:13px;font-weight:600}.clusterlane-support-btn:hover{background:rgba(255,255,255,.08)}.clusterlane-support-btn--primary{background:#fff;color:#0f172a;border-color:#fff}.clusterlane-support-btn--primary:hover{background:#e2e8f0}.clusterlane-support-btn:disabled{opacity:.8;cursor:default}.clusterlane-support-body{padding:20px 22px 24px}.clusterlane-support-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px}.clusterlane-support-grid+.clusterlane-support-grid{margin-top:14px}.clusterlane-support-card{border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;background:#f8fafc;min-width:0}.clusterlane-support-card--wide{grid-column:1/-1}.clusterlane-support-card--full{grid-column:1/-1}.clusterlane-support-card--full .clusterlane-support-table{width:100%}.clusterlane-support-card--full .clusterlane-support-table th{width:240px}.clusterlane-support-card--wide .clusterlane-support-table{columns:2 320px;display:block}.clusterlane-support-card--wide .clusterlane-support-table tr{display:flex;break-inside:avoid}.clusterlane-support-card--wide .clusterlane-support-table th{flex:1 1 auto}.clusterlane-support-card h3{margin:0 0 10px;font-size:13px;font-weight:700;color:#0f172a;display:flex;align-items:center;gap:6px}.clusterlane-support-table{width:100%;border-collapse:collapse;font-size:12.5px}.clusterlane-support-table th{text-align:left;font-weight:500;color:#64748b;padding:5px 10px 5px 0;vertical-align:top;white-space:nowrap}.clusterlane-support-table td{color:#0f172a;padding:5px 0;word-break:break-word}.clusterlane-support-table tr+tr td,.clusterlane-support-table tr+tr th{border-top:1px solid #e2e8f0}.clusterlane-support-row--warn td{color:#92400e;font-weight:600}.clusterlane-support-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:1px}.clusterlane-support-dot--ok{background:#10b981}.clusterlane-support-dot--warn{background:#f59e0b}.clusterlane-support-note{margin:8px 0 6px;font-size:12.5px;color:#475569}.clusterlane-support-code{margin:0 0 8px;padding:10px 12px;border-radius:8px;background:#0f172a;color:#e2e8f0;font-size:12px;line-height:1.5;overflow-x:auto;white-space:pre}.clusterlane-support-list{margin:0;padding-left:18px;font-size:12.5px;color:#334155}.clusterlane-support-list li{margin-bottom:6px}.clusterlane-support-report{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}@media (max-width:600px){.clusterlane-support-header{padding:16px}.clusterlane-support-body{padding:16px}.clusterlane-support-grid{grid-template-columns:1fr}.clusterlane-support-table th{white-space:normal}.clusterlane-support-card--full .clusterlane-support-table th{width:38%}}.clusterlane-network-btn{display:inline-flex;align-items:center;text-decoration:none}.clusterlane-network-btn:focus{box-shadow:none}.clusterlane-network-table-wrap{overflow-x:auto;border:1px solid #e2e8f0;border-radius:10px}.clusterlane-network-table{width:100%;border-collapse:collapse;font-size:13px;background:#fff}.clusterlane-network-table th{text-align:left;padding:10px 12px;background:#f8fafc;color:#64748b;font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;border-bottom:1px solid #e2e8f0;white-space:nowrap}.clusterlane-network-table td{padding:10px 12px;border-bottom:1px solid #f1f5f9;vertical-align:top;color:#0f172a}.clusterlane-network-table tr:last-child td{border-bottom:0}.clusterlane-network-row--inactive td{color:#94a3b8}.clusterlane-network-meta{display:block;color:#94a3b8;font-size:12px;margin-top:2px}.clusterlane-network-pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:600;background:#eff6ff;color:#1d4ed8}.clusterlane-network-pill--off{background:#f1f5f9;color:#94a3b8}.clusterlane-network-actions{white-space:nowrap}.clusterlane-network-actions a{margin-right:10px;font-weight:600;text-decoration:none}.clusterlane-network-pager{margin-top:14px}.clusterlane-network-pager .page-numbers{display:inline-block;min-width:28px;padding:4px 8px;margin-right:4px;text-align:center;border:1px solid #e2e8f0;border-radius:6px;text-decoration:none;color:#0f172a}.clusterlane-network-pager .page-numbers.current{background:#0f172a;color:#fff;border-color:#0f172a}.clusterlane-network-ai{margin-bottom:16px}.clusterlane-network-ai-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin:10px 0}.clusterlane-network-ai-grid label{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:600;color:#475569}.clusterlane-network-ai-grid input,.clusterlane-network-ai-grid select{height:36px;line-height:20px;border:1.5px solid #e2e8f0;border-radius:8px;padding:0 10px;max-width:100%}.clusterlane-network-ai-key{grid-column:span 2}@media (max-width:600px){.clusterlane-network-ai-key{grid-column:auto}}.clusterlane-network-ai-policy{margin:10px 0 12px;border:0;padding:0}.clusterlane-network-ai-policy legend{font-size:12px;font-weight:600;color:#475569;margin-bottom:6px}.clusterlane-network-ai-policy label{display:block;font-size:13px;margin-bottom:4px}.clusterlane-network-ai-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.clusterlane-support-btn--dark{background:#0f172a;color:#fff;border-color:#0f172a}.clusterlane-support-btn--dark:hover{background:#1e293b}.clusterlane-support-btn--light{background:#fff;color:#0f172a;border-color:#cbd5e1}.clusterlane-support-btn--light:hover{background:#f1f5f9}.clusterlane-network-ai-saved{margin:0 0 8px;color:#15803d;font-weight:600;font-size:13px}.clusterlane-network-ai-notice{margin:4px 0 12px;padding:10px 14px;border-radius:8px;font-size:13px;background:#eff6ff;border-left:4px solid #3b82f6;color:#1e3a8a}.clusterlane-network-ai-notice--locked{background:#f8fafc;border-left-color:#64748b;color:#334155}.clusterlane-ai-fieldset{border:0;margin:0;padding:0;min-width:0}.clusterlane-ai-fieldset[disabled]{opacity:.6}.clusterlane-ai-usage{margin-top:18px;padding-top:14px;border-top:1px solid #e2e8f0}.clusterlane-ai-usage h3{margin:0 0 4px;font-size:14px}.clusterlane-ai-usage-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:12px 0}.clusterlane-ai-usage-stats>div{background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:10px 12px}.clusterlane-ai-usage-stats strong{display:block;font-size:18px;color:#0f172a}.clusterlane-ai-usage-stats span{font-size:12px;color:#64748b}.clusterlane-ai-usage-table{border-collapse:collapse;margin:6px 0 10px;font-size:13px;min-width:320px}.clusterlane-ai-usage-table td,.clusterlane-ai-usage-table th{text-align:left;padding:5px 16px 5px 0;border-bottom:1px solid #f1f5f9}.clusterlane-ai-usage-table th{color:#64748b;font-weight:600;font-size:12px}.clusterlane-network-ai-limit{display:flex;flex-direction:column;gap:4px;margin:0 0 12px;font-size:12px;font-weight:600;color:#475569;max-width:520px}.clusterlane-network-ai-limit input{width:120px;height:36px;border:1.5px solid #e2e8f0;border-radius:8px;padding:0 10px}.clusterlane-network-ai-limit .clusterlane-support-note{font-weight:400;margin:0}.clusterlane-tip{margin-left:4px;outline:0}.clusterlane-tip:focus-visible .clusterlane-tip-icon{box-shadow:0 0 0 2px #93c5fd}.clusterlane-tip-pop{position:absolute;z-index:1000010;display:none;width:max-content;max-width:300px;padding:10px 12px;border-radius:8px;background:#1e293b;color:#e2e8f0;font-size:12px;line-height:1.5;font-weight:400;text-align:left;white-space:normal;box-shadow:0 8px 24px rgba(15,23,42,.28)}.clusterlane-tip-pop.is-open{display:block}.clusterlane-tip-pop::after{content:"";position:absolute;left:var(--clusterlane-tip-arrow,50%);transform:translateX(-50%);border:6px solid transparent;top:100%;border-top-color:#1e293b}.clusterlane-tip-pop.is-below::after{top:auto;bottom:100%;border-top-color:transparent;border-bottom-color:#1e293b}.clusterlane-tip-pop-title{display:block;color:#fff;font-size:12.5px;margin-bottom:4px}.clusterlane-tip-pop-text{display:block}.clusterlane-tip-pop-row{display:block;margin-top:6px;padding-top:6px;border-top:1px solid rgba(255,255,255,.1)}.clusterlane-tip-pop-row b{color:#93c5fd;font-weight:600}.clusterlane-ci-hero .clusterlane-tip-icon,.clusterlane-graph-infobar .clusterlane-tip-icon,.clusterlane-hero .clusterlane-tip-icon,.clusterlane-page-hero .clusterlane-tip-icon{background:rgba(255,255,255,.18);color:#e2e8f0}.clusterlane-ci-hero .clusterlane-tip:hover .clusterlane-tip-icon,.clusterlane-graph-infobar .clusterlane-tip:hover .clusterlane-tip-icon,.clusterlane-hero .clusterlane-tip:hover .clusterlane-tip-icon{background:#3b82f6;color:#fff}.clusterlane-help-term-fix,.clusterlane-help-term-good{display:inline-block;margin-top:4px;font-size:12.5px;color:#475569}.clusterlane-help-term-good strong{color:#15803d}.clusterlane-help-term-fix strong{color:#1d4ed8}.clusterlane-pro-badge{display:inline-block;padding:1px 7px;border-radius:999px;background:linear-gradient(135deg,#7c3aed,#2563eb);color:#fff;font-size:10px;font-weight:700;letter-spacing:.04em;line-height:16px;vertical-align:middle}.clusterlane-pro-badge--tab{margin-left:5px;font-size:9px;padding:0 6px}.clusterlane-pro-example-banner{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 12px;padding:9px 12px;border-radius:8px;background:#f5f3ff;border:1px dashed #a78bfa;color:#4c1d95;font-size:12.5px;line-height:1.45}.clusterlane-pro-example-text{flex:1 1 260px}.clusterlane-pro-example-link{font-weight:600;color:#6d28d9;text-decoration:none;white-space:nowrap}.clusterlane-pro-example-link:hover{text-decoration:underline}.clusterlane-pro-example button[disabled],.clusterlane-pro-example select[disabled]{cursor:not-allowed}.clusterlane-pro-example-pair{font-size:13px;margin:6px 0 10px;color:#334155}.clusterlane-pro-example-variants{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:12px}.clusterlane-pro-example-variant{border:1px solid #e2e8f0;border-radius:10px;padding:10px 12px;background:#fff}.clusterlane-pro-example-variant p{margin:6px 0 0;font-size:13px;color:#334155;line-height:1.5}.clusterlane-pro-example-variant-head{display:flex;justify-content:space-between;font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.03em}.clusterlane-pro-example-pos{font-weight:600;color:#6d28d9;text-transform:none}.clusterlane-pro-note{display:flex;align-items:flex-start;gap:12px;padding:12px 14px;border-radius:10px;background:#f8fafc;border:1px solid #e2e8f0}.clusterlane-pro-note strong{display:block;font-size:13px;color:#0f172a}.clusterlane-pro-note p{margin:2px 0 0;font-size:12.5px;color:#475569}.clusterlane-pro-note .clusterlane-pro-example-link{margin-left:auto;align-self:center}.clusterlane-inspector-head{--ih-ink:#16223a;--ih-ink-2:#22314f;--ih-text:#9fb0c8;--ih-text-hi:#f1f5f9;--ih-muted:#7c8ea8;--ih-node:#60a5fa;position:relative;overflow:visible;background:linear-gradient(135deg,var(--ih-ink) 0,var(--ih-ink-2) 100%);border-radius:14px;padding:18px 24px 22px;color:var(--ih-text-hi);box-shadow:0 10px 28px rgba(15,23,42,.18);isolation:isolate}.clusterlane-inspector-head__mark{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;flex-shrink:0;border-radius:10px;background:linear-gradient(135deg,#22314f,#16223a);box-shadow:0 0 0 1px rgba(255,255,255,.08) inset}.clusterlane-inspector-head__bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.clusterlane-inspector-head__page{font-size:13px;font-weight:600;color:var(--ih-text)}.clusterlane-inspector-head__search{position:relative;margin-left:auto;width:min(420px,100%)}.clusterlane-inspector-head__search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--ih-muted);pointer-events:none}#clusterlane-ai-head .clusterlane-inspector-head__search-input{width:100%!important;height:38px!important;min-height:0!important;margin:0!important;padding:0 12px 0 36px!important;border:1px solid rgba(255,255,255,.12)!important;border-radius:10px!important;background:rgba(255,255,255,.07)!important;color:var(--ih-text-hi)!important;font-size:14px!important;box-shadow:none!important;transition:border-color 120ms,background 120ms,box-shadow 120ms}#clusterlane-ai-head .clusterlane-inspector-head__search-input::placeholder{color:var(--ih-muted);opacity:1}#clusterlane-ai-head .clusterlane-inspector-head__search-input:focus{outline:0!important;border-color:var(--ih-node)!important;background:rgba(255,255,255,.11)!important;box-shadow:0 0 0 3px rgba(96,165,250,.22)!important}.clusterlane-inspector-head__results{position:absolute!important;top:calc(100% + 6px);left:0;right:0;z-index:50;max-width:none!important;margin:0!important;box-shadow:0 14px 34px rgba(15,23,42,.25)}.clusterlane-inspector-head__subject{margin-top:22px;max-width:62%;position:relative;z-index:1}#clusterlane-ai-head .clusterlane-inspector-head__title{margin:0!important;padding:0!important;font-size:clamp(1.4rem, 1.1rem + 1.1vw, 2rem)!important;font-weight:800!important;line-height:1.15!important;letter-spacing:-.015em!important;color:#fff!important;text-wrap:balance}.clusterlane-inspector-head__meta{margin-top:10px;font-size:12.5px;color:var(--ih-text);line-height:1.8}.clusterlane-inspector-head__meta span[style*="color:#64748b"]{color:var(--ih-text)!important}.clusterlane-inspector-head__meta strong{color:var(--ih-text-hi);font-weight:600}.clusterlane-inspector-head .clusterlane-ai-tax-pill--cat{background:rgba(59,130,246,.22);color:#bfdbfe;border-color:rgba(96,165,250,.35)}.clusterlane-inspector-head .clusterlane-ai-tax-pill--tag{background:rgba(255,255,255,.08);color:#cbd5e1;border-color:rgba(255,255,255,.14)}.clusterlane-inspector-head__actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}.clusterlane-inspector-head__btn{display:inline-flex;align-items:center;height:34px;padding:0 14px;border-radius:9px;border:1px solid rgba(255,255,255,.16);color:var(--ih-text-hi);font-size:13px;font-weight:600;text-decoration:none;transition:background 120ms,border-color 120ms}.clusterlane-inspector-head__btn:focus,.clusterlane-inspector-head__btn:hover{background:rgba(255,255,255,.08);color:#fff;border-color:rgba(255,255,255,.26);box-shadow:none}.clusterlane-inspector-head__btn:focus-visible{outline:2px solid var(--ih-node);outline-offset:2px}.clusterlane-inspector-head__btn--primary{background:#fff;border-color:#fff;color:var(--ih-ink)}.clusterlane-inspector-head__btn--primary:focus,.clusterlane-inspector-head__btn--primary:hover{background:#e2e8f0;border-color:#e2e8f0;color:var(--ih-ink)}.clusterlane-inspector-head__empty{margin-top:16px}.clusterlane-inspector-head__empty>p{margin:0;max-width:120ch;color:var(--ih-text);font-size:13.5px;line-height:1.6}.clusterlane-inspector-head .clusterlane-ai-recent-label{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--ih-muted)}.clusterlane-inspector-head .clusterlane-ai-recent-item{background:rgba(255,255,255,.05)!important;border-color:rgba(255,255,255,.1)!important;color:var(--ih-text-hi)!important}.clusterlane-inspector-head .clusterlane-ai-recent-item:hover{background:rgba(255,255,255,.1)!important;border-color:rgba(96,165,250,.45)!important}.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__subject{display:none}.clusterlane-inspector-head:not(:has(#clusterlane-ai-title:empty)) .clusterlane-inspector-head__empty{display:none}.clusterlane-inspector-head--has-image::before{content:"";position:absolute;top:0;right:0;bottom:0;width:48%;z-index:-1;border-radius:0 14px 14px 0;background-image:var(--clusterlane-header-img);background-size:cover;background-position:center;-webkit-mask-image:linear-gradient(to right,transparent 0%,rgba(0,0,0,.55) 45%,#000 100%);mask-image:linear-gradient(to right,transparent 0%,rgba(0,0,0,.55) 45%,#000 100%);opacity:.85}.clusterlane-ci-hero{background:linear-gradient(135deg,#16223a 0,#22314f 100%)!important;border-radius:14px!important;padding:22px 26px!important;box-shadow:0 10px 28px rgba(15,23,42,.18)}.clusterlane-ci-hero-title .clusterlane-page-hero-sub{color:#9fb0c8}.clusterlane-ci-stat-label{color:#8ea0ba}@media (max-width:782px){.clusterlane-inspector-head{padding:16px 16px 18px}.clusterlane-inspector-head__search{width:100%;margin-left:0}.clusterlane-inspector-head__subject{max-width:none;margin-top:18px}.clusterlane-inspector-head--has-image::before{width:100%;opacity:.28;-webkit-mask-image:none;mask-image:none;border-radius:14px}.clusterlane-ci-hero{padding:18px!important}}.clusterlane-page-hero{background:linear-gradient(135deg,#16223a 0,#22314f 100%)!important;border-radius:14px!important;padding:20px 24px!important;box-shadow:0 10px 28px rgba(15,23,42,.16)}.clusterlane-page-hero .clusterlane-page-hero-sub{color:#9fb0c8}.clusterlane-ci-detail-header{background:linear-gradient(135deg,#16223a 0,#22314f 100%)!important;padding:20px 24px 16px!important}.clusterlane-ci-detail-title-row{align-items:center!important;gap:18px;margin-bottom:14px!important}.clusterlane-ci-detail-titles{min-width:0;flex:1 1 auto}.clusterlane-ci-detail-kind{display:block;font-size:12.5px;color:#9fb0c8;margin-bottom:4px}#clusterlane-ci-detail-panel .clusterlane-ci-detail-label{margin:0!important;color:#fff!important;text-transform:none!important;letter-spacing:-.015em!important;font-size:clamp(1.3rem, 1.05rem + .9vw, 1.8rem)!important;font-weight:800!important;line-height:1.15!important}.clusterlane-ci-detail-actions{display:flex;align-items:center;gap:8px;flex-shrink:0}.clusterlane-ci-detail-ai-btn{height:32px!important;font-size:12.5px!important;cursor:pointer;background:0 0;font-family:inherit}.clusterlane-ci-detail-health{display:flex;align-items:center;gap:10px;flex-shrink:0;padding:6px 14px 6px 12px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08)}.clusterlane-ci-detail-health[hidden]{display:none}.clusterlane-ci-health-node{width:10px;height:10px;border-radius:50%;background:var(--clusterlane-hn);box-shadow:0 0 0 3px color-mix(in srgb,var(--clusterlane-hn) 25%,transparent),0 0 14px 2px color-mix(in srgb,var(--clusterlane-hn) 60%,transparent)}.clusterlane-ci-health-value{font-size:1.35rem;font-weight:800;color:#fff;line-height:1}.clusterlane-ci-health-label{font-size:12px;color:#9fb0c8}.clusterlane-ci-detail-health--good{--clusterlane-hn:#22c55e}.clusterlane-ci-detail-health--fair{--clusterlane-hn:#f59e0b}.clusterlane-ci-detail-health--weak{--clusterlane-hn:#ef4444}.clusterlane-ci-detail-health .clusterlane-tip-icon{background:rgba(255,255,255,.18);color:#e2e8f0}.clusterlane-ci-detail-header .clusterlane-gib-head{display:none!important}.clusterlane-ci-detail-header .clusterlane-graph-infobar{color:#9fb0c8!important;font-size:12px}.clusterlane-ci-detail-header .clusterlane-graph-infobar strong{color:#f1f5f9}.clusterlane-ci-detail-header .clusterlane-graph-infobar>span:first-of-type:not(.clusterlane-gib-head){padding-left:0}@media (max-width:782px){.clusterlane-ci-detail-title-row{flex-wrap:wrap}.clusterlane-ci-detail-titles{flex-basis:100%}.clusterlane-ci-detail-health{order:2}.clusterlane-ci-detail-actions{order:3;margin-left:auto}}#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn{color:#f1f5f9;border:1px solid rgba(255,255,255,.16);border-radius:9px;padding:0 14px}#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.26)}#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}#clusterlane-ci-detail-panel .clusterlane-ci-detail-ai-btn:disabled{opacity:.6;cursor:progress}.clusterlane-g{display:inline-block;width:1.05em;height:1.05em;vertical-align:-.16em;margin-right:.38em;flex-shrink:0;overflow:visible}#clusterlane-sa-print .clusterlane-g,.clusterlane-action-btn__icon .clusterlane-g,.clusterlane-ai-cluster-type .clusterlane-g,.clusterlane-ai-recent-icon .clusterlane-g,.clusterlane-ci-cluster-type .clusterlane-g,.clusterlane-improve-panel-icon .clusterlane-g,.clusterlane-ob-icon .clusterlane-g,.clusterlane-site-analysis-icon .clusterlane-g,.clusterlane-support-icon .clusterlane-g,.clusterlane-tms-empty-icon .clusterlane-g,.clusterlane-type-filter .clusterlane-g,.clusterlane-type-icon .clusterlane-g{margin-right:0}.clusterlane-ob-icon .clusterlane-g,.clusterlane-tms-empty-icon .clusterlane-g{stroke-width:1.6;color:#16a34a}.clusterlane-site-analysis-icon .clusterlane-g,.clusterlane-support-icon .clusterlane-g{color:#fff}.clusterlane-site-analysis-icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:10px;font-size:17px!important;color:#fff;background:linear-gradient(135deg,#22314f,#16223a);box-shadow:0 0 0 1px rgba(255,255,255,.08) inset}.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__page{font-size:1.6rem;font-weight:800;color:#fff;letter-spacing:-.01em;line-height:1.15}.clusterlane-inspector-head .clusterlane-ai-recent-title{color:#e2e8f0}.clusterlane-inspector-head .clusterlane-ai-recent-icon{color:#7c8ea8;opacity:1}.clusterlane-inspector-head .clusterlane-ai-recent-item:hover .clusterlane-ai-recent-title{color:#fff}.clusterlane-inspector-head .clusterlane-ai-recent-item:hover .clusterlane-ai-recent-icon{color:#60a5fa}.clusterlane-inspector-head .clusterlane-ai-recent-item:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}.clusterlane-wrap:has(#clusterlane-ai-title:not(:empty)) #clusterlane-ai-browse{display:none}.clusterlane-browse{margin-top:20px}.clusterlane-browse__head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px}.clusterlane-browse__head .clusterlane-ai-section-title{margin:0}.clusterlane-browse__summary{display:inline-flex;align-items:center;flex-wrap:wrap;gap:0;padding:4px 6px;border-radius:999px;background:#f1f5f9;font-size:12.5px;color:#64748b;line-height:1.4}.clusterlane-browse__summary:empty{display:none}.clusterlane-browse__sum-part{display:inline-flex;align-items:center;gap:5px;padding:2px 8px}.clusterlane-browse__sum-part+.clusterlane-browse__sum-part{border-left:1px solid #e2e8f0}.clusterlane-browse__sum-part b{color:#16223a;font-weight:700}.clusterlane-browse__sum-part--sort span{color:#94a3b8}.clusterlane-browse__sum-orphans{margin:0;border:0;border-radius:999px;background:#e4f0f8;color:#245f84;font:inherit;cursor:pointer;transition:background 120ms}.clusterlane-browse__sum-part+.clusterlane-browse__sum-orphans{border-left:0;margin-left:4px}.clusterlane-browse__sum-orphans b{color:#245f84}.clusterlane-browse__sum-orphans:hover{background:#d3e6f3}.clusterlane-browse__sum-orphans:focus-visible{outline:2px solid #3b82f6;outline-offset:1px}.clusterlane-browse__toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:14px}.clusterlane-browse__search{position:relative;flex:1 1 240px;max-width:360px}.clusterlane-browse__search .clusterlane-g{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:#94a3b8;margin:0}#clusterlane-ai-browse .clusterlane-browse__search input,#clusterlane-ai-browse select#clusterlane-ab-cluster,#clusterlane-ai-browse select#clusterlane-ab-lang,#clusterlane-ai-browse select#clusterlane-ab-meta,#clusterlane-seot .clusterlane-browse__search input,#clusterlane-seot select#clusterlane-seot-cluster,#clusterlane-seot select#clusterlane-seot-lang{height:36px;min-height:0;margin:0;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px;color:#0f172a;background-color:#fff;box-shadow:none}#clusterlane-ai-browse .clusterlane-browse__search input,#clusterlane-seot .clusterlane-browse__search input{width:100%;padding:0 12px 0 34px}#clusterlane-ai-browse select#clusterlane-ab-cluster,#clusterlane-ai-browse select#clusterlane-ab-lang,#clusterlane-ai-browse select#clusterlane-ab-meta,#clusterlane-seot select#clusterlane-seot-cluster,#clusterlane-seot select#clusterlane-seot-lang{padding:0 32px 0 12px;max-width:260px}#clusterlane-ai-browse select#clusterlane-ab-meta.is-active{border-color:#2563eb;background-color:#eff6ff;color:#1e3a8a;font-weight:600}#clusterlane-ai-browse .clusterlane-browse__search input:focus,#clusterlane-ai-browse select#clusterlane-ab-cluster:focus,#clusterlane-ai-browse select#clusterlane-ab-lang:focus,#clusterlane-ai-browse select#clusterlane-ab-meta:focus,#clusterlane-seot .clusterlane-browse__search input:focus,#clusterlane-seot select#clusterlane-seot-cluster:focus,#clusterlane-seot select#clusterlane-seot-lang:focus{outline:0;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}.clusterlane-browse__roles{display:inline-flex;flex-wrap:wrap;gap:4px;padding:3px;border-radius:10px;background:#f1f5f9}.clusterlane-browse__roles button{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 11px;border:0;border-radius:7px;background:0 0;color:#475569;font-size:12.5px;font-weight:500;cursor:pointer}.clusterlane-browse__roles button em{font-style:normal;color:#94a3b8;font-weight:600}.clusterlane-browse__roles button:hover{color:#0f172a}.clusterlane-browse__roles button.is-active{background:#fff;color:#0f172a;font-weight:600;box-shadow:0 1px 2px rgba(15,23,42,.08)}.clusterlane-browse__roles button:focus-visible{outline:2px solid #3b82f6;outline-offset:1px}.clusterlane-role-dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0}.clusterlane-role-dot--pillar{background:#e76c2e}.clusterlane-role-dot--support{background:#f5c842}.clusterlane-role-dot--light{background:#90b865}.clusterlane-role-dot--orphan{background:#5ba4cf}.clusterlane-browse__table-wrap{overflow-x:auto;border:1px solid #e2e8f0;border-radius:10px;transition:opacity 150ms}.clusterlane-browse.is-loading .clusterlane-browse__table-wrap{opacity:.55}.clusterlane-browse__table{width:100%;border-collapse:collapse;font-size:13px}.clusterlane-browse__table>thead>tr>th{position:sticky;top:0;z-index:1;padding:9px 12px;background:#f8fafc;border-bottom:1px solid #e2e8f0;text-align:left;font-size:12px;font-weight:600;color:#64748b;white-space:nowrap}.clusterlane-browse__table>tbody>tr>td.num,.clusterlane-browse__table>thead>tr>th.num{text-align:right;font-variant-numeric:tabular-nums}.clusterlane-browse__table>thead>tr>th button{all:unset;cursor:pointer}.clusterlane-browse__table>thead>tr>th button:hover{color:#0f172a}.clusterlane-browse__table>thead>tr>th button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;border-radius:3px}.clusterlane-browse__table>thead>tr>th button.is-sorted{color:#0f172a}.clusterlane-browse__table>thead>tr>th button.is-sorted::after{content:" ↓"}.clusterlane-browse__table>thead>tr>th button.is-sorted[data-dir=asc]::after{content:" ↑"}.clusterlane-browse__table>tbody>tr>td{padding:10px 12px;border-bottom:1px solid #f1f5f9;color:#334155;vertical-align:middle}.clusterlane-browse__table>tbody>tr{cursor:pointer;transition:background .1s}.clusterlane-browse__table>tbody>tr:hover{background:#f8fafc}.clusterlane-browse__table>tbody>tr:last-child>td{border-bottom:0}.clusterlane-browse__col-title{min-width:240px}.clusterlane-browse__open{all:unset;display:block;font-weight:600;color:#0f172a;cursor:pointer}.clusterlane-browse__open:hover,tr:hover .clusterlane-browse__open{color:#1d4ed8}.clusterlane-browse__open:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;border-radius:3px}.clusterlane-browse__cluster{display:block;margin-top:2px;font-size:12px;color:#94a3b8}.clusterlane-browse__more{color:#64748b;font-weight:600}.clusterlane-role-pill{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:#334155;white-space:nowrap}.clusterlane-browse__score{display:inline-block;min-width:30px;font-weight:700}.clusterlane-browse__score--good{color:#15803d}.clusterlane-browse__score--fair{color:#b45309}.clusterlane-browse__score--weak{color:#dc2626}.clusterlane-browse__table>tbody>tr>td.clusterlane-browse__zero{color:#dc2626;font-weight:700}.clusterlane-browse__muted{color:#94a3b8;font-size:12.5px}.clusterlane-browse__empty{text-align:center;padding:28px 12px!important;color:#64748b;cursor:default}.clusterlane-browse__pager{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;min-height:20px}.clusterlane-browse__pages{display:inline-flex;align-items:center;gap:10px;font-size:12.5px;color:#475569}.clusterlane-browse__pages button{height:30px;padding:0 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#0f172a;font-size:12.5px;font-weight:600;cursor:pointer}.clusterlane-browse__pages button:hover:not(:disabled){border-color:#93c5fd;background:#eff6ff}.clusterlane-browse__pages button:disabled{opacity:.45;cursor:default}.clusterlane-inspector-head__btn--quiet{border-color:transparent;color:#9fb0c8}.clusterlane-inspector-head__btn--quiet:hover{color:#fff}@media (max-width:782px){.clusterlane-browse__opt{display:none}.clusterlane-browse__search{max-width:none;flex-basis:100%}#clusterlane-ai-browse select#clusterlane-ab-cluster,#clusterlane-ai-browse select#clusterlane-ab-lang,#clusterlane-ai-browse select#clusterlane-ab-meta,#clusterlane-seot select#clusterlane-seot-cluster,#clusterlane-seot select#clusterlane-seot-lang{flex:1 1 auto;max-width:none}.clusterlane-browse__col-title{min-width:0}}.clusterlane-ai-table{table-layout:fixed}.clusterlane-ai-table>thead>tr>th:nth-child(2){width:132px}.clusterlane-ai-table>thead>tr>th:nth-child(3){width:84px}.clusterlane-ai-table>thead>tr>th:nth-child(4){width:64px}.clusterlane-ai-table>thead>tr>th:nth-child(5){width:72px}.clusterlane-ai-table>thead>tr>th:nth-child(6){width:84px}.clusterlane-ai-table>thead>tr>th:nth-child(7){width:112px}.clusterlane-ai-table .clusterlane-browse__col-title{min-width:0}.clusterlane-ai-table .clusterlane-browse__open{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.clusterlane-ai-table .clusterlane-browse__cluster{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clusterlane-ai-table .clusterlane-role-pill{max-width:100%}@media (max-width:782px){.clusterlane-ai-table>thead>tr>th:nth-child(2){width:14px;font-size:0}.clusterlane-ai-table>thead>tr>th:nth-child(2) .clusterlane-tip{display:none}.clusterlane-ai-table .clusterlane-role-pill{font-size:0;gap:0}.clusterlane-ai-table>thead>tr>th:nth-child(3){width:48px}.clusterlane-ai-table>thead>tr>th:nth-child(4){width:34px}.clusterlane-ai-table>tbody>tr>td,.clusterlane-ai-table>thead>tr>th{padding-left:7px;padding-right:7px}.clusterlane-ai-table .clusterlane-thumb--md{width:30px;height:30px;margin-right:8px}}.clusterlane-inspector-head:has(#clusterlane-ai-title:empty){padding:18px 24px 18px}.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__search{display:none}.clusterlane-inspector-head:has(#clusterlane-ai-title:empty) .clusterlane-inspector-head__empty{margin-top:10px}.clusterlane-inspector-head__empty>p{margin-left:46px!important}.clusterlane-inspector-head #clusterlane-ai-recent-slot:not(:empty){margin:12px 0 0 46px}.clusterlane-inspector-head #clusterlane-ai-recent-slot>div{margin-top:0!important}.clusterlane-inspector-head .clusterlane-ai-recent-label{display:inline-block;margin:0 8px 6px 0}.clusterlane-inspector-head .clusterlane-ai-recent-list{flex-direction:row;flex-wrap:wrap;gap:6px}.clusterlane-inspector-head .clusterlane-ai-recent-item{width:auto;max-width:260px;padding:5px 12px 5px 10px;border-radius:999px;gap:6px}.clusterlane-inspector-head .clusterlane-ai-recent-title{font-size:12.5px}@media (max-width:782px){.clusterlane-inspector-head #clusterlane-ai-recent-slot:not(:empty),.clusterlane-inspector-head__empty>p{margin-left:0!important}}.clusterlane-ci-table tr.clusterlane-ci-cluster-row{display:table-row;padding:0;border:0;border-radius:0;margin:0;background:0 0}.clusterlane-ci-table tr.clusterlane-ci-cluster-row:hover{background:#f8fafc}.clusterlane-ci-table tr.clusterlane-ci-cluster-row--active,.clusterlane-ci-table tr.clusterlane-ci-cluster-row--active:hover{background:#eff6ff;box-shadow:inset 3px 0 0 #3b82f6}.clusterlane-ci-table .clusterlane-ci-cluster-type{display:inline-flex;color:#94a3b8;margin-right:8px;vertical-align:-2px}.clusterlane-ci-table .clusterlane-browse__open{display:inline-block;max-width:calc(100% - 28px);vertical-align:top;text-align:left}.clusterlane-ci-table>tbody>tr>td.clusterlane-browse__zero{color:#dc2626}.clusterlane-band-dot--weak{background:#ef4444}.clusterlane-band-dot--fair{background:#f59e0b}.clusterlane-band-dot--good{background:#22c55e}.clusterlane-ci-health-cell{display:inline-flex;align-items:center;gap:8px;font-weight:700;color:#0f172a}.clusterlane-ci-health-cell .clusterlane-ci-health-node{width:8px;height:8px}.clusterlane-ci-health-cell.clusterlane-ci-detail-health--good{color:#15803d}.clusterlane-ci-health-cell.clusterlane-ci-detail-health--fair{color:#b45309}.clusterlane-ci-health-cell.clusterlane-ci-detail-health--weak{color:#dc2626}.clusterlane-browse__table>tbody>tr>td.clusterlane-browse__muted{color:#94a3b8}.clusterlane-ci-table>tbody>tr.clusterlane-ci-detail-row,.clusterlane-ci-table>tbody>tr.clusterlane-ci-detail-row:hover{cursor:default;background:#f1f5f9}.clusterlane-ci-table>tbody>tr.clusterlane-ci-detail-row>td{padding:0 12px 14px 12px;border-bottom:1px solid #e2e8f0;box-shadow:inset 3px 0 0 #3b82f6}.clusterlane-ci-table>tbody>tr.clusterlane-ci-cluster-row--active>td{border-bottom-color:transparent}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel{margin-top:0!important}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel>.clusterlane-panel-card{margin:0;box-shadow:0 6px 18px rgba(15,23,42,.1);cursor:auto}.clusterlane-improve-block-icon .clusterlane-g{margin-right:0}.clusterlane-ci-table{table-layout:fixed}.clusterlane-ci-table>thead>tr>th:nth-child(2){width:172px}.clusterlane-ci-table>thead>tr>th:nth-child(3){width:84px}.clusterlane-ci-table>thead>tr>th:nth-child(4){width:92px}.clusterlane-ci-table>thead>tr>th:nth-child(5){width:84px}.clusterlane-ci-table>thead>tr>th:nth-child(6){width:92px}.clusterlane-ci-table>tbody>tr.clusterlane-ci-cluster-row>td.clusterlane-browse__col-title{overflow-wrap:anywhere}.clusterlane-ci-members{table-layout:fixed}.clusterlane-ci-members>thead>tr>th:nth-child(2){width:140px}.clusterlane-ci-members>thead>tr>th:nth-child(3){width:48px}.clusterlane-ci-members>thead>tr>th:nth-child(4){width:52px}.clusterlane-ci-members>thead>tr>th:nth-child(5){width:60px}.clusterlane-ci-members>thead>tr>th:nth-child(6){width:64px}.clusterlane-ci-members>thead>tr>th:nth-child(7){width:116px}.clusterlane-ci-members .clusterlane-ci-member-link{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.clusterlane-ci-members .clusterlane-browse__titlecell>.clusterlane-thumb{flex-shrink:0}.clusterlane-ca-table{table-layout:fixed}.clusterlane-ca-table>thead>tr>th:nth-child(2){width:84px}.clusterlane-ca-table>thead>tr>th:nth-child(3){width:92px}.clusterlane-ca-table>thead>tr>th:nth-child(4){width:92px}.clusterlane-ca-table>thead>tr>th:nth-child(5){width:168px}.clusterlane-ca-table .clusterlane-ca-name{overflow-wrap:anywhere}@media (max-width:782px){.clusterlane-ca-table,.clusterlane-ci-members{table-layout:auto}.clusterlane-ci-members-wrap .clusterlane-ci-members>tbody>tr>td,.clusterlane-ci-members-wrap .clusterlane-ci-members>thead>tr>th{padding-left:6px;padding-right:6px}.clusterlane-ci-members>tbody>tr>td:is(:nth-child(4),:nth-child(6),:nth-child(7)),.clusterlane-ci-members>thead>tr>th:is(:nth-child(4),:nth-child(6),:nth-child(7)){display:none}.clusterlane-ci-members>thead>tr>th:nth-child(2){font-size:0}.clusterlane-ci-members>thead>tr>th .clusterlane-tip{display:none}.clusterlane-ci-members>thead>tr>th:not(:first-child){width:1%;white-space:nowrap}.clusterlane-ci-members .clusterlane-role-pill{font-size:0;gap:0}.clusterlane-ci-members>tbody>tr>td:nth-child(2){white-space:nowrap}.clusterlane-ci-members .clusterlane-ci-chosen{margin:0 0 0 2px;padding:0;background:0 0;font-size:0}.clusterlane-ci-members .clusterlane-ci-chosen::before{content:"★";font-size:11px;color:#e76c2e}.clusterlane-ci-members .clusterlane-thumb--sm{display:none}.clusterlane-ca-table>tbody>tr>td,.clusterlane-ca-table>thead>tr>th{padding-left:7px;padding-right:7px}.clusterlane-ca-table>tbody>tr.clusterlane-ca-row>td:nth-child(2),.clusterlane-ca-table>thead>tr>th:nth-child(2){display:none}.clusterlane-ca-table>thead>tr>th .clusterlane-tip{display:none}.clusterlane-ca-table>thead>tr>th:not(:first-child){width:1%;white-space:nowrap}.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn{display:flex;justify-content:center;box-sizing:border-box;margin:0 0 4px}}@media (max-width:782px){.clusterlane-ci-table{table-layout:fixed;width:100%}.clusterlane-ci-table>tbody>tr.clusterlane-ci-cluster-row>td:is(:nth-child(2),:nth-child(3),:nth-child(5)),.clusterlane-ci-table>thead>tr>th:is(:nth-child(2),:nth-child(3),:nth-child(5)){display:table-cell!important;width:0;padding:0!important;border:0;overflow:hidden;font-size:0;visibility:hidden}.clusterlane-ci-table>tbody>tr>td,.clusterlane-ci-table>thead>tr>th{padding-left:8px;padding-right:8px}.clusterlane-ci-table>thead>tr>th:nth-child(4){width:66px}.clusterlane-ci-table>thead>tr>th:nth-child(6){width:72px}.clusterlane-ci-table>tbody>tr.clusterlane-ci-cluster-row>td.clusterlane-browse__col-title{overflow-wrap:anywhere}.clusterlane-ci-table>tbody>tr.clusterlane-ci-detail-row>td{padding:0 4px 10px}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel,.clusterlane-ci-detail-row #clusterlane-ci-detail-panel>.clusterlane-panel-card{max-width:100%;min-width:0}.clusterlane-ci-detail-row .clusterlane-ci-detail-header{padding:16px 14px 12px!important}.clusterlane-ci-detail-row .clusterlane-graph-infobar{row-gap:4px}.clusterlane-ci-detail-row .clusterlane-graph-infobar>span{white-space:normal}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel>.clusterlane-panel-card>div[style*="padding:20px 24px"]{padding:14px 12px!important}.clusterlane-ci-detail-row #clusterlane-ci-detail-graph{height:300px!important}.clusterlane-ci-detail-row table{display:block;max-width:100%;overflow-x:auto}.clusterlane-ci-detail-row .clusterlane-improve-block-head{gap:8px}.clusterlane-ci-detail-row .clusterlane-improve-block-title{min-width:0;overflow-wrap:anywhere}.clusterlane-ci-detail-row [class*=clusterlane-gap-],.clusterlane-ci-detail-row [class*=clusterlane-tms-]{overflow-wrap:break-word}}.clusterlane-tm-question{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:14px;color:#0f172a;font-weight:600}.clusterlane-tm-question label{font-weight:600}.clusterlane-tm-question .clusterlane-tms-select{height:36px;min-height:0;border:1.5px solid #e2e8f0;border-radius:9px;padding:0 30px 0 10px;font-size:13px;font-weight:500}.clusterlane-tm-hint{margin:10px 0 16px;font-size:13px;color:#64748b}.clusterlane-tm-actions-bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.clusterlane-tm-status{font-size:13px;color:#64748b}.clusterlane-tm-linkbtn{margin-left:auto;background:0 0;border:0;padding:0;color:#64748b;font-size:12.5px;text-decoration:underline;cursor:pointer}.clusterlane-tm-linkbtn:hover{color:#0f172a}.clusterlane-tm-progress-label{font-size:12px;color:#64748b;margin-top:4px}.clusterlane-tm-preview{opacity:.8}.clusterlane-tm-preview-label{margin:0 0 8px;font-size:12px;font-weight:600;color:#94a3b8}.clusterlane-tm-preview .clusterlane-tm-group{border-style:dashed}.clusterlane-tm-preview .clusterlane-tm-btn{pointer-events:none}.clusterlane-tm-summary{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin:0 0 12px;font-size:13.5px;color:#334155}.clusterlane-tm-orphan-count{color:#1d4ed8;font-weight:600}.clusterlane-tm-restore{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:10px 14px;margin-bottom:14px;border-radius:10px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;font-size:13px}.clusterlane-tm-group{background:#fff;border:1px solid #e2e8f0;border-radius:12px;margin-bottom:14px;overflow:hidden}.clusterlane-tm-group-head{display:flex;align-items:center;gap:12px;padding:10px 16px;background:#f8fafc;border-bottom:1px solid #e2e8f0;font-size:13px;color:#64748b}.clusterlane-tm-group-head a{color:#0f172a;text-decoration:none}.clusterlane-tm-group-head a:hover strong{text-decoration:underline}.clusterlane-tm-group-count{margin-left:auto;font-size:12px;color:#94a3b8}.clusterlane-tm-row{display:flex;align-items:flex-start;gap:16px;padding:12px 16px;border-bottom:1px solid #f1f5f9;transition:background 150ms}.clusterlane-tm-row:last-child{border-bottom:0}.clusterlane-tm-row--orphan{box-shadow:inset 3px 0 0 #5ba4cf}.clusterlane-tm-row-main{flex:1;min-width:0}.clusterlane-tm-sentence{margin:0 0 4px;font-size:13.5px;line-height:1.55;color:#475569}.clusterlane-tm-hit{color:#1d4ed8;font-weight:600;text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:3px;background:#eff6ff;border-radius:3px;padding:0 2px}.clusterlane-tm-target{margin:0;font-size:12.5px;color:#64748b;display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}.clusterlane-tm-target strong{color:#0f172a}.clusterlane-tm-target .clusterlane-g{margin-right:0;color:#94a3b8}.clusterlane-tm-note{font-size:12px;color:#94a3b8}.clusterlane-tm-row-actions{display:flex;gap:6px;flex-shrink:0}.clusterlane-tm-btn{display:inline-flex;align-items:center;height:30px;padding:0 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#334155;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap}.clusterlane-tm-btn:hover:not(:disabled){border-color:#cbd5e1;background:#f8fafc}.clusterlane-tm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-tm-btn:disabled{opacity:.6;cursor:progress}.clusterlane-tm-btn--apply{background:#2563eb;border-color:#2563eb;color:#fff}.clusterlane-tm-btn--apply:hover:not(:disabled){background:#1d4ed8;border-color:#1d4ed8}.clusterlane-tm-btn--apply:disabled,.clusterlane-tm-btn--apply[aria-disabled=true]{background:#e2e8f0;border-color:#e2e8f0;color:#94a3b8;opacity:1;cursor:not-allowed}.clusterlane-tm-apply-all{height:28px}.clusterlane-tm-more{display:flex;align-items:center;flex-wrap:wrap;gap:6px 12px;margin:14px 0 0}.clusterlane-tm-row--linked{background:#f0fdf4}.clusterlane-tm-done{margin:0;font-size:13px;color:#166534;display:flex;align-items:center;flex-wrap:wrap;gap:4px}.clusterlane-tm-done .clusterlane-g{color:#16a34a}@media (max-width:782px){.clusterlane-tm-row{flex-direction:column;gap:10px}.clusterlane-tm-row-actions{align-self:flex-start}.clusterlane-tm-group-head{flex-wrap:wrap}.clusterlane-tm-linkbtn{margin-left:0}}.clusterlane-ls2-modes{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px}.clusterlane-ls2-modes button{display:flex;align-items:flex-start;gap:12px;text-align:left;padding:14px 16px;border:1.5px solid #e2e8f0;border-radius:12px;background:#fff;cursor:pointer;color:#334155;transition:border-color 120ms,background 120ms,box-shadow 120ms}.clusterlane-ls2-modes button .clusterlane-g{font-size:20px;color:#94a3b8;margin:2px 0 0}.clusterlane-ls2-modes button strong{display:block;font-size:14px;color:#0f172a}.clusterlane-ls2-modes button em{display:block;font-style:normal;font-size:12.5px;color:#64748b;margin-top:2px}.clusterlane-ls2-modes button:hover{border-color:#cbd5e1}.clusterlane-ls2-modes button.is-active{border-color:#3b82f6;background:#eff6ff;box-shadow:0 0 0 3px rgba(59,130,246,.12)}.clusterlane-ls2-modes button.is-active .clusterlane-g{color:#2563eb}.clusterlane-ls2-modes button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-ls2-q{margin:4px 0 12px;font-size:13.5px;font-weight:600;color:#0f172a}.clusterlane-ls2-toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:12px}.clusterlane-ls2 .clusterlane-browse__search input{width:100%;height:36px;min-height:0;margin:0;padding:0 12px 0 34px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px;box-shadow:none}.clusterlane-ls2 .clusterlane-browse__search input:focus{outline:0;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}.clusterlane-ls2-search{max-width:420px}.clusterlane-ls2-results{max-width:420px;margin-top:6px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;box-shadow:0 10px 24px rgba(15,23,42,.1);overflow:hidden}.clusterlane-ls2-results button{display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:7px 14px;border:0;border-bottom:1px solid #f1f5f9;background:#fff;font-size:13px;color:#0f172a;cursor:pointer}.clusterlane-ls2-res-title{flex:1 1 auto;min-width:0}.clusterlane-ls2-res-note{flex:none;font-size:11.5px;color:#94a3b8}.clusterlane-ls2-results button.is-content .clusterlane-ls2-res-title{color:#475569}.clusterlane-ls2-results button:hover{background:#f8fafc}.clusterlane-ls2-results p{margin:0;padding:10px 14px}.clusterlane-ls2-list{display:flex;flex-direction:column;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}.clusterlane-ls2-pick{display:flex;align-items:center;gap:14px;padding:10px 14px;text-align:left;border:0;border-bottom:1px solid #f1f5f9;background:#fff;cursor:pointer;color:#334155}.clusterlane-ls2-pick:last-child{border-bottom:0}.clusterlane-ls2-pick:hover{background:#f8fafc}.clusterlane-ls2-pick:focus-visible{outline:2px solid #3b82f6;outline-offset:-2px}.clusterlane-ls2-pick-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.clusterlane-ls2-pick-main strong{color:#0f172a;font-size:13.5px}.clusterlane-ls2-pick-meta{display:flex;align-items:center;gap:10px;flex-shrink:0}.clusterlane-ls2-pick-go{flex-shrink:0;font-size:12.5px;font-weight:600;color:#2563eb;display:inline-flex;align-items:center;gap:4px}.clusterlane-ls2-pick-go .clusterlane-g{margin:0}.clusterlane-ls2-chosen{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;margin-bottom:14px;border-radius:10px;background:#16223a;color:#cbd5e1;font-size:13.5px}.clusterlane-ls2-chosen strong{color:#fff}.clusterlane-ls2-chosen .clusterlane-role-pill{color:#e2e8f0}.clusterlane-ls2-chosen .clusterlane-tm-note{color:#9fb0c8}.clusterlane-ls2-row{border:1px solid #e2e8f0;border-radius:10px;margin-bottom:10px;background:#fff;overflow:hidden}.clusterlane-ls2-row-head{display:flex;align-items:center;gap:12px;padding:10px 14px}.clusterlane-ls2-row-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.clusterlane-ls2-row-main strong{color:#0f172a;font-size:13.5px}.clusterlane-ls2-row-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px;color:#94a3b8}.clusterlane-ls2-row-body:empty{display:none}.clusterlane-ls2-row-body{border-top:1px solid #f1f5f9;padding:12px 14px;background:#fbfcfe}.clusterlane-ls2-options{display:grid;gap:8px}.clusterlane-ls2-option{padding:10px 12px;border:1px solid #e2e8f0;border-radius:9px;background:#fff}.clusterlane-ls2-sentence{margin:0 0 8px;font-size:13.5px;line-height:1.55;color:#334155}.clusterlane-ls2-option-foot{display:flex;align-items:center;justify-content:space-between;gap:10px}.clusterlane-ls2-regen{margin:10px 0 0;font-size:12px;color:#94a3b8}.clusterlane-ls2-row--linked{border-color:#bbf7d0}.clusterlane-ls2-row--linked .clusterlane-ls2-row-body{background:#f0fdf4}.clusterlane-ls2-linked{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;font-size:13px;color:#166534}.clusterlane-ls2-linked .clusterlane-g{color:#16a34a;margin:0}.clusterlane-ls2-linked-actions{margin-left:auto;display:flex;gap:6px}.clusterlane-ls2-loading{display:flex;align-items:center;gap:8px;font-size:13px;color:#64748b;padding:6px 0}.clusterlane-ls2-empty{margin:8px 0;padding:14px;border:1px dashed #cbd5e1;border-radius:10px;font-size:13px;color:#64748b}.clusterlane-ls2-other{margin:12px 0 0}@media (max-width:782px){.clusterlane-ls2-modes{grid-template-columns:1fr}.clusterlane-ls2-pick{flex-wrap:wrap}.clusterlane-ls2-pick-go{width:100%}.clusterlane-ls2-row-head{flex-wrap:wrap}.clusterlane-ls2-option-foot{flex-wrap:wrap}.clusterlane-ls2-linked-actions{margin-left:0}}.clusterlane-ls2-why{font-size:12px;color:#475569}.clusterlane-ls2-ai:empty,.clusterlane-ls2-why:empty{display:none}.clusterlane-ls2-ai{display:flex;align-items:center;gap:8px;font-size:12.5px;color:#1e3a8a;margin-top:2px}.clusterlane-ls2-fit{display:inline-flex;gap:2px;flex-shrink:0}.clusterlane-ls2-fit i{width:7px;height:7px;border-radius:50%;background:#e2e8f0;display:inline-block}.clusterlane-ls2-fit i.on{background:#3b82f6}.clusterlane-ls2-row--weak{opacity:.7}.clusterlane-ls2-rankbar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;margin:0 0 10px}.clusterlane-ls2-rank-state{display:inline-flex;align-items:center;gap:6px}.clusterlane-ls2-rank-err{color:#b91c1c}.clusterlane-ca-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.clusterlane-ca-card-head h3{margin:0;font-size:15px;color:#0f172a;display:flex;align-items:center}.clusterlane-ca-bulk{font-size:12.5px;color:#cbd5e1}.clusterlane-ca-term{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid #f3f4f6;font-size:13.5px;cursor:pointer}.clusterlane-ca-term:last-child{border-bottom:0}.clusterlane-ca-term input{margin:0}.clusterlane-ca-term-name{display:flex;flex-direction:column;gap:2px;color:#0f172a;min-width:0}.clusterlane-ca-fit{font-size:11.5px}.clusterlane-ca-fit--low{color:#15803d}.clusterlane-ca-fit--mid{color:#b45309}.clusterlane-ca-fit--high{color:#b91c1c}.clusterlane-ca-status{font-size:12.5px;white-space:nowrap}.clusterlane-ca-status--off{color:#94a3b8;font-size:12px}.clusterlane-ca-status .clusterlane-ci-health-cell{font-size:12.5px}.clusterlane-ca-savebar{position:sticky;bottom:12px;z-index:50;margin-top:16px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;padding:12px 16px;border-radius:12px;background:#16223a;color:#e2e8f0;font-size:13.5px;box-shadow:0 12px 30px rgba(15,23,42,.28)}.clusterlane-ca-savebar[hidden]{display:none}.clusterlane-ca-savebar strong{color:#fff}.clusterlane-ca-savebar .clusterlane-tm-note{color:#9fb0c8}.clusterlane-ca-savebar-actions{display:flex;gap:8px}.clusterlane-ca-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:14px 0}.clusterlane-ca-toolbar .clusterlane-tm-btn .clusterlane-g{margin-right:6px}.clusterlane-ca-pro-note{flex:1 1 320px}.clusterlane-ca-table td.clusterlane-ca-actions{text-align:right;white-space:nowrap}.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn{height:28px;padding:0 10px;text-decoration:none}.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-g{margin:0}.clusterlane-ca-table>tbody>tr.clusterlane-ca-row{cursor:default}.clusterlane-ca-table>tbody>tr.is-editing{background:#eff6ff;box-shadow:inset 3px 0 0 #3b82f6}.clusterlane-ca-table>tbody>tr.clusterlane-ca-editor-row>td{padding:0 12px 14px;background:#f8fafc;box-shadow:inset 3px 0 0 #3b82f6}.clusterlane-ca-table>tbody>tr.clusterlane-ca-editor-row:hover{background:#f8fafc}@keyframes clusterlane-ca-new{from{background:#dcfce7}to{background:0 0}}.clusterlane-ca-row--new{animation:clusterlane-ca-new 2.5s ease-out}.clusterlane-ca-editor{margin:4px 0 16px;padding:16px;border:1px solid #bfdbfe;border-radius:12px;background:#fff;max-width:760px}.clusterlane-ca-e-label{display:block;font-size:12.5px;font-weight:600;color:#334155;margin:0 0 6px}.clusterlane-ca-e-name{display:block;width:100%;max-width:420px;height:36px;margin:4px 0 14px;padding:0 12px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:14px;font-weight:400}.clusterlane-ca-e-name:focus{outline:0;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}.clusterlane-ca-e-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}.clusterlane-ca-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 4px 4px 10px;border-radius:999px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;font-size:12.5px}.clusterlane-ca-chip button{width:20px;height:20px;border:0;border-radius:50%;background:0 0;color:#64748b;font-size:15px;line-height:1;cursor:pointer}.clusterlane-ca-chip button:hover{background:#dbeafe;color:#b91c1c}.clusterlane-ca-e-search{max-width:420px}.clusterlane-ca-editor .clusterlane-browse__search input{width:100%;height:36px;min-height:0;margin:0;padding:0 12px 0 34px;border:1.5px solid #e2e8f0;border-radius:9px;font-size:13px}.clusterlane-ca-e-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;margin-top:14px}.clusterlane-ca-e-err{color:#b91c1c!important}.clusterlane-ca-ideas{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;margin-bottom:16px}.clusterlane-ca-idea{display:flex;flex-direction:column;padding:14px;border:1px solid #e2e8f0;border-radius:12px;background:#fff}.clusterlane-ca-idea--dupe{border-color:#fde68a}.clusterlane-ca-idea--done{border-color:#bbf7d0;background:#f0fdf4}.clusterlane-ca-idea-head{display:flex;justify-content:space-between;gap:8px;align-items:baseline}.clusterlane-ca-idea-head strong{color:#0f172a;font-size:14px}.clusterlane-ca-idea-why{margin:6px 0;font-size:12.5px;color:#475569;line-height:1.5}.clusterlane-ca-idea-warn{margin:0 0 6px;font-size:12px;color:#92400e}.clusterlane-ca-idea-list{margin:4px 0 12px;padding-left:18px;font-size:12.5px;color:#334155}.clusterlane-ca-idea-list li{margin:2px 0}.clusterlane-ca-idea-foot{margin-top:auto}.clusterlane-ca-idea-ok{color:#15803d;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:4px}@media (max-width:782px){.clusterlane-ca-term{grid-template-columns:auto 1fr auto}.clusterlane-ca-term .clusterlane-checkbox-count{display:none}.clusterlane-ca-table td.clusterlane-ca-actions{white-space:normal}}.clusterlane-ca-status--pending{color:#1d4ed8;font-weight:600;font-size:12px}.clusterlane-ca-idea-spans{margin:0 0 6px;font-size:12px;color:#1d4ed8}.clusterlane-ca-ideas-hidden{margin:-6px 0 10px}.clusterlane-set-tabs{display:flex;flex-wrap:wrap;gap:4px;padding:4px;margin:0 0 18px;background:#fff;border:1px solid #e2e8f0;border-radius:12px}.clusterlane-set-tabs button{display:inline-flex;align-items:center;gap:8px;height:38px;padding:0 16px;border:0;border-radius:9px;background:0 0;color:#475569;font-size:13.5px;font-weight:600;cursor:pointer}.clusterlane-set-tabs button .clusterlane-g{margin:0;color:#94a3b8}.clusterlane-set-tabs button em{font-style:normal;color:#2563eb;font-size:18px;line-height:0}.clusterlane-set-tabs button:hover{background:#f8fafc;color:#0f172a}.clusterlane-set-tabs button.is-active{background:#16223a;color:#fff}.clusterlane-set-tabs button.is-active .clusterlane-g{color:#60a5fa}.clusterlane-set-tabs button.is-active em{color:#60a5fa}.clusterlane-set-tabs button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-set-panel[hidden]{display:none}.clusterlane-set-card{margin-bottom:16px}.clusterlane-set-card-head{padding:16px 22px 0}.clusterlane-set-card-head h2{display:flex;align-items:center;gap:2px;margin:0;font-size:15px}.clusterlane-set-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.clusterlane-set-intro{margin:0 0 14px;font-size:13.5px;color:#475569}.clusterlane-set-note{display:flex;align-items:center;gap:4px;margin:0 0 6px;padding:10px 12px;border-radius:9px;background:#eff6ff;color:#1e3a8a;font-size:13px}.clusterlane-set-note a{font-weight:600}.clusterlane-ca-mincount{margin:0 0 14px;font-size:13px;color:#334155;display:flex;flex-wrap:wrap;align-items:center;gap:8px}.clusterlane-ca-mincount input{width:64px;height:30px;min-height:0;margin:0 4px;border:1.5px solid #e2e8f0;border-radius:8px}@media (max-width:782px){.clusterlane-set-tabs{flex-wrap:nowrap;overflow-x:auto}.clusterlane-set-tabs button{flex-shrink:0;padding:0 12px}}.clusterlane-wrap>.clusterlane-hero,.clusterlane-wrap>.clusterlane-inspector-head{margin-top:16px}.clusterlane-stickybar .dashicons.clusterlane-spin{animation:clusterlane-spin .8s linear infinite}.clusterlane-stickybar__action-btn.is-busy{opacity:1;color:#fff;border-color:rgba(96,165,250,.6);background:rgba(59,130,246,.18);cursor:progress}.clusterlane-stickybar__action-btn.is-done,.clusterlane-stickybar__action-btn.is-done:hover{color:#fff;background:#16a34a;border-color:#16a34a}.clusterlane-stickybar__action-btn.is-failed,.clusterlane-stickybar__action-btn.is-failed:hover{color:#fff;background:#dc2626;border-color:#dc2626}@media (prefers-reduced-motion:reduce){.clusterlane-stickybar .dashicons.clusterlane-spin{animation:none}}.clusterlane-stickybar__toast{left:auto;right:34px;transform:none;bottom:auto;top:calc(100% - 4px);display:flex;align-items:flex-start;gap:10px;max-width:min(460px,calc(100vw - 60px));white-space:normal;padding:11px 12px 11px 12px;border-radius:11px;background:#fff;color:#0f172a;font-size:13px;line-height:1.45;border:1px solid #e2e8f0;border-left:4px solid #3b82f6;box-shadow:0 14px 34px rgba(15,23,42,.22);pointer-events:auto;animation:clusterlane-toast-in .18s ease-out}.clusterlane-stickybar__toast[hidden]{display:none}@keyframes clusterlane-toast-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}.clusterlane-stickybar__toast--success{border-left-color:#16a34a}.clusterlane-stickybar__toast--error{border-left-color:#dc2626}.clusterlane-stickybar__toast-icon{flex-shrink:0;color:#3b82f6;font-size:16px;line-height:1;margin-top:1px}.clusterlane-stickybar__toast--success .clusterlane-stickybar__toast-icon{color:#16a34a}.clusterlane-stickybar__toast--error .clusterlane-stickybar__toast-icon{color:#dc2626}.clusterlane-stickybar__toast--info .clusterlane-stickybar__toast-icon .clusterlane-g{animation:clusterlane-spin 1s linear infinite}.clusterlane-stickybar__toast-icon .clusterlane-g{margin:0}.clusterlane-stickybar__toast-msg{flex:1}.clusterlane-stickybar__toast-link{background:0 0;border:0;padding:0;margin-left:2px;color:#1d4ed8;font-weight:600;text-decoration:underline;cursor:pointer;font-size:13px}.clusterlane-stickybar__toast-close{flex-shrink:0;width:22px;height:22px;border:0;border-radius:50%;background:0 0;color:#94a3b8;font-size:16px;line-height:1;cursor:pointer}.clusterlane-stickybar__toast-close:hover{background:#f1f5f9;color:#0f172a}@media (max-width:782px){.clusterlane-stickybar__toast{right:16px;left:16px;max-width:none}}@media (prefers-reduced-motion:reduce){.clusterlane-stickybar__toast,.clusterlane-stickybar__toast--info .clusterlane-stickybar__toast-icon .clusterlane-g{animation:none}}.clusterlane-stickybar.is-scanning .clusterlane-stickybar__inner::after{content:"";position:absolute;left:14px;right:14px;bottom:0;height:3px;border-radius:3px;background:linear-gradient(90deg,#3b82f6 var(--clusterlane-scan-pct,0),rgba(255,255,255,.08) var(--clusterlane-scan-pct,0));transition:background .3s}.clusterlane-stickybar__toast-stop{flex-shrink:0;height:26px;padding:0 10px;border:1px solid #e2e8f0;border-radius:7px;background:#fff;color:#b91c1c;font-size:12px;font-weight:600;cursor:pointer}.clusterlane-stickybar__toast-stop:hover{background:#fef2f2;border-color:#fecaca}.clusterlane-hero-firstscan{display:flex;align-items:center;gap:6px;margin:4px 24px 20px;padding:10px 14px;border-radius:10px;background:rgba(59,130,246,.15);color:#dbeafe;font-size:13.5px}.clusterlane-hero-firstscan .clusterlane-g{transform:rotate(-90deg);color:#60a5fa}.clusterlane-improve-summary:empty{display:none}.clusterlane-improve-summary{margin:0 0 14px;padding:14px 16px;border-radius:12px;background:#f8fafc;border:1px solid #e2e8f0}.clusterlane-improve-first{display:flex;align-items:center;gap:6px;margin:0 0 10px;font-size:14px;font-weight:600;color:#0f172a}.clusterlane-improve-first .clusterlane-g{color:#2563eb;margin:0}.clusterlane-improve-chips{display:flex;flex-wrap:wrap;gap:6px}.clusterlane-improve-chips button{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border:1px solid #e2e8f0;border-radius:999px;background:#fff;color:#475569;font-size:12.5px;font-weight:600;cursor:pointer}.clusterlane-improve-chips button.is-todo{border-color:#bfdbfe;background:#eff6ff;color:#1d4ed8}.clusterlane-improve-chips button:hover{border-color:#93c5fd}.clusterlane-improve-chips button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-improve-block-titles{display:flex;flex-direction:column;gap:2px;min-width:0}.clusterlane-improve-block-title{font-weight:700;color:#0f172a}.clusterlane-improve-block-sub{font-size:12px;font-weight:400;color:#64748b;line-height:1.4}.clusterlane-improve-block-header:focus-visible{outline:2px solid #3b82f6;outline-offset:-2px;border-radius:10px}.clusterlane-ci-tms-list{margin:0 0 8px}.clusterlane-tm-in{font-weight:600;color:#334155}.clusterlane-ci-tms-foot{margin:6px 2px 0;font-size:12.5px;color:#94a3b8}.clusterlane-ci-al .clusterlane-ls2-row{margin-bottom:8px}.clusterlane-ci-al-from{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12.5px;color:#64748b}.clusterlane-ci-al-from strong{color:#0f172a}.clusterlane-ci-al-from select{max-width:380px;height:30px;min-height:0;padding:0 28px 0 8px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:12.5px;color:#0f172a}.clusterlane-gap-help{margin:0 0 10px;font-size:12px;color:#64748b;line-height:1.5}.clusterlane-gap-item-actions{display:flex;gap:6px;flex-wrap:wrap}.clusterlane-gap-draft-done{background:#16a34a!important;border-color:#16a34a!important;color:#fff!important;text-decoration:none}.clusterlane-gap-draft-done .clusterlane-g{margin-right:4px}.clusterlane-ci-members td.clusterlane-ci-member-actions{text-align:right;white-space:nowrap}.clusterlane-ci-members td.clusterlane-ci-member-actions .clusterlane-tm-btn{height:28px;padding:0 8px}.clusterlane-ci-members td.clusterlane-ci-member-actions .clusterlane-g{margin:0}.clusterlane-ci-member-link{color:#0f172a;font-weight:600;text-decoration:none}.clusterlane-ci-member-link:hover{color:#1d4ed8;text-decoration:underline}.clusterlane-ci-members>tbody>tr{cursor:default}@media (max-width:782px){.clusterlane-ci-members td:nth-child(4),.clusterlane-ci-members td:nth-child(6),.clusterlane-ci-members th:nth-child(4),.clusterlane-ci-members th:nth-child(6){display:none}.clusterlane-ci-al-from select{max-width:100%;width:100%}}.clusterlane-ci-members-box{margin-top:18px;padding:14px 16px 16px;border-radius:12px;background:#f8fafc;border:1px solid #e2e8f0}.clusterlane-ci-members-title{display:flex;align-items:center;gap:2px;margin:0;font-size:13.5px;font-weight:700;color:#0f172a}.clusterlane-ci-members-title span{color:#94a3b8;font-weight:600;margin-left:4px}.clusterlane-ci-members-hint{margin:2px 0 10px;font-size:12px;color:#64748b}.clusterlane-ci-members-wrap{background:#fff}.clusterlane-ci-members{font-size:12.5px}.clusterlane-ci-members>thead>tr>th{background:#f1f5f9;font-size:11.5px;padding:7px 10px}.clusterlane-ci-members>tbody>tr>td{padding:7px 10px}.clusterlane-ci-members>tbody>tr.clusterlane-ci-member-row{cursor:pointer}.clusterlane-ci-members>tbody>tr.clusterlane-ci-member-row:hover{background:#eff6ff}.clusterlane-ci-members>tbody>tr.clusterlane-ci-member-row:focus-visible{outline:2px solid #3b82f6;outline-offset:-2px}.clusterlane-ci-member-link{font-weight:500}.clusterlane-ci-focus{margin-top:16px;padding:16px 24px 20px}.clusterlane-ci-focus-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px}.clusterlane-ci-focus-head .clusterlane-ai-section-title{margin:0;padding:0;border:0}.clusterlane-ci-focus-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.clusterlane-ci-focus-card{--clusterlane-hn:#94a3b8;display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:12px 14px 12px 16px;text-align:left;cursor:pointer;background:#fff;border:1px solid #e2e8f0;border-left:3px solid var(--clusterlane-hn);border-radius:12px;font:inherit;color:#0f172a;transition:border-color .12s,box-shadow .12s,transform .12s}.clusterlane-ci-focus-card:hover{border-color:#cbd5e1;border-left-color:var(--clusterlane-hn);box-shadow:0 4px 14px rgba(15,23,42,.08);transform:translateY(-1px)}.clusterlane-ci-focus-card:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-ci-focus-top{display:flex;align-items:center;gap:10px;width:100%;font-size:12.5px}.clusterlane-ci-focus-rank{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#16223a;color:#fff;font-size:11px;font-weight:700}.clusterlane-ci-focus-size{margin-left:auto;color:#94a3b8;font-size:12px}.clusterlane-ci-focus-name{font-size:14px;font-weight:700;line-height:1.3}.clusterlane-ci-focus-why{font-size:12.5px;color:#475569;line-height:1.45}.clusterlane-ci-focus-go{margin-top:auto;padding-top:4px;font-size:12px;font-weight:600;color:#2563eb}.clusterlane-ci-focus-go .clusterlane-g{margin:0 0 0 2px}.clusterlane-ci-focus-done{margin:0;font-size:13px;color:#15803d}.clusterlane-dash-focus{margin-top:16px;padding:16px 24px 20px}.clusterlane-dash-focus__head{display:flex;align-items:center;gap:6px 12px;flex-wrap:wrap;margin-bottom:12px}.clusterlane-dash-focus .clusterlane-dash-focus__head h2{display:flex;align-items:center;gap:6px;margin:0;padding:0;border:0;font-size:14px;font-weight:700;color:#0f172a;text-transform:none;letter-spacing:0}.clusterlane-dash-focus__head .clusterlane-tm-note{margin-left:auto}a.clusterlane-ci-focus-card{text-decoration:none}a.clusterlane-ci-focus-card:focus,a.clusterlane-ci-focus-card:hover{color:#0f172a}@media (max-width:782px){.clusterlane-dash-focus{padding:14px 16px 16px}.clusterlane-dash-focus__head .clusterlane-tm-note{margin-left:0}}.clusterlane-ci-focus-done .clusterlane-g{color:#16a34a}.clusterlane-ci-view .clusterlane-seg-btn{display:inline-flex;align-items:center}.clusterlane-ci-view .clusterlane-seg-btn .clusterlane-g{margin-right:.35em}.clusterlane-ci-map-note{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 12px;margin:0 0 8px;font-size:11.5px;color:#94a3b8}#clusterlane-ci-map-cap{color:#b45309}@media (max-width:782px){.clusterlane-ci-focus{padding:14px 16px 16px}.clusterlane-ci-focus-grid{grid-template-columns:1fr}}a.clusterlane-sa-chip{text-decoration:none;cursor:pointer;transition:filter .12s,box-shadow .12s}a.clusterlane-sa-chip:hover{filter:brightness(.96);box-shadow:0 0 0 2px rgba(15,23,42,.08)}a.clusterlane-sa-chip:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-sa-cluster-link{color:inherit;font-weight:600;text-decoration:underline;text-decoration-color:#93c5fd;text-underline-offset:2px}.clusterlane-sa-cluster-link:hover{color:#1d4ed8;text-decoration-color:currentColor}.clusterlane-sm-card{margin-top:20px}.clusterlane-sm-card .clusterlane-site-analysis-title strong+.clusterlane-tip{margin-left:4px}.clusterlane-sm-card .clusterlane-dash-graph-controls{display:flex;align-items:center;gap:8px}.clusterlane-sm-body{position:relative}.clusterlane-sm-canvas{position:relative;height:320px;border-radius:10px;overflow:hidden;background:#f8fafc;border:1px solid #e2e8f0}.clusterlane-sm-empty{margin:0;position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:13px}.clusterlane-sm-tile{position:absolute;box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-start;gap:2px;padding:8px 10px;border:2px solid #fff;border-radius:8px;background:var(--sm-bg,#f1f5f9);color:var(--sm-text,#334155);box-shadow:inset 3px 0 0 var(--sm-edge,#94a3b8);text-decoration:none;cursor:pointer;transition:filter .12s,box-shadow .12s}.clusterlane-sm-tile:focus-visible,.clusterlane-sm-tile:hover{filter:brightness(.95) saturate(1.1);color:var(--sm-text,#334155)}.clusterlane-sm-tile:focus-visible{outline:2px solid #3b82f6;outline-offset:-4px}.clusterlane-sm-tile.is-current{box-shadow:inset 3px 0 0 var(--sm-edge),inset 0 0 0 2px #16223a}.clusterlane-sm-name{font-size:12.5px;font-weight:700;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-sm-meta{margin-top:auto;font-size:11.5px;opacity:.8;line-height:1.3;overflow:hidden}.clusterlane-sm-tile--md .clusterlane-sm-name{font-size:13.5px;white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.clusterlane-sm-tile--sm{padding:5px 7px}.clusterlane-sm-tile--sm .clusterlane-sm-name{font-size:11px}.clusterlane-sm-tile--xs{padding:0}.clusterlane-sm-tile--rest{background:#f1f5f9;color:#64748b;box-shadow:none;border-style:solid;justify-content:center;align-items:center}.clusterlane-sm-tile--rest .clusterlane-sm-name{font-weight:600}.clusterlane-sm-tip{position:absolute;z-index:5;display:none;pointer-events:none;max-width:320px;background:#1e2535;color:#e2e8f0;border-radius:7px;padding:7px 10px;font-size:12px;line-height:1.4;box-shadow:0 4px 14px rgba(0,0,0,.3)}.clusterlane-sm-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px;margin-top:10px}.clusterlane-sm-legend{display:flex;flex-wrap:wrap;align-items:center;gap:6px 14px;font-size:11.5px;color:#64748b}.clusterlane-sm-legend-title{font-weight:600;color:#475569}.clusterlane-sm-legend-item{display:inline-flex;align-items:center;gap:6px}.clusterlane-sm-swatch{width:12px;height:12px;border-radius:3px;border:2px solid;box-sizing:border-box}.clusterlane-sm-legend-note{color:#94a3b8}.clusterlane-sm-takeaway{margin:0;font-size:12.5px;font-weight:600;color:#334155}.clusterlane-sm-takeaway .clusterlane-g{color:#64748b}@media (max-width:782px){.clusterlane-sm-canvas{height:420px}.clusterlane-sm-foot{flex-direction:column;align-items:flex-start}}.clusterlane-ai-terms{margin-top:8px}.clusterlane-ai-terms-more{display:inline-block;margin-top:4px;padding:2px 9px;border-radius:12px;cursor:pointer;font:inherit;font-size:11px;font-weight:600;line-height:inherit;background:0 0;color:var(--ih-text,#64748b);border:1px dashed rgba(255,255,255,.22)}.clusterlane-ai-terms-more:hover{color:#fff;border-color:rgba(255,255,255,.4)}.clusterlane-ai-terms-more:focus-visible{outline:2px solid var(--ih-node,#3b82f6);outline-offset:2px}.clusterlane-ai-terms-rest[hidden]{display:none}.clusterlane-serp{margin-top:14px;max-width:600px;padding:12px 16px 14px;border-radius:12px;background:rgba(15,23,42,.55);border:1px solid rgba(255,255,255,.12);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);font-family:Arial,Helvetica,sans-serif;line-height:1.45}.clusterlane-serp__bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:10px;font-family:inherit}.clusterlane-serp__label{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--ih-muted,#7c8ea8);margin-right:4px}.clusterlane-serp__chip{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:11px;font-weight:600;padding:1px 8px;border-radius:999px;line-height:18px}.clusterlane-serp__chip--ok{background:rgba(34,197,94,.16);color:#86efac}.clusterlane-serp__chip--warn{background:rgba(245,158,11,.18);color:#fcd34d}.clusterlane-serp__chip--bad{background:rgba(239,68,68,.2);color:#fca5a5}.clusterlane-serp__src{margin-left:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:11px;color:var(--ih-muted,#7c8ea8);text-decoration:none}a.clusterlane-serp__src:hover{color:#fff;text-decoration:underline}.clusterlane-serp__site{display:flex;align-items:center;gap:10px}.clusterlane-serp__fav{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:#303134;border:1px solid rgba(255,255,255,.14);color:#e8eaed;font-size:12px;font-weight:700;flex-shrink:0}.clusterlane-serp__site>span:last-child{display:flex;flex-direction:column;min-width:0}.clusterlane-serp__name{font-size:14px;color:#dadce0;line-height:1.3}.clusterlane-serp__url{font-size:12px;color:#bdc1c6;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-serp__title{margin-top:6px;font-size:20px;line-height:1.3;color:#99c3ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-serp__desc{margin-top:3px;font-size:14px;line-height:1.58;color:#bdc1c6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.clusterlane-serp__desc--none{font-style:italic;color:#9aa0a6}.clusterlane-seo-check p{margin:0 0 4px;font-size:13px;color:#334155}.clusterlane-seo-check .clusterlane-g{color:#16a34a}.clusterlane-seo-check-icon.is-warn .clusterlane-g{color:#d97706}.clusterlane-seo-check-counts strong{color:#b45309}@media (max-width:782px){.clusterlane-serp{max-width:none}.clusterlane-serp__title{font-size:17px}.clusterlane-serp__src{margin-left:0}}.clusterlane-serp__ai{display:inline-flex;align-items:center;gap:2px;height:22px;padding:0 9px;margin-left:8px;border-radius:999px;border:1px solid rgba(147,197,253,.35);background:rgba(59,130,246,.18);color:#bfdbfe;font:600 11px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer}.clusterlane-serp__ai:hover{background:rgba(59,130,246,.32);color:#fff}.clusterlane-serp__ai:focus-visible{outline:2px solid var(--ih-node,#60a5fa);outline-offset:2px}.clusterlane-serp__ai .clusterlane-g{margin-right:3px}.clusterlane-serp__src{margin-left:auto}.clusterlane-serp__src+.clusterlane-serp__ai{margin-left:8px}.clusterlane-serp__ai+.clusterlane-serp__ai{margin-left:6px}.clusterlane-serp__ai:disabled{opacity:.6;cursor:progress}.clusterlane-serp__review{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;margin-top:10px;padding:8px 10px;border-radius:8px;background:rgba(124,58,237,.16);border:1px solid rgba(196,181,253,.28);font:12.5px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e2e8f0}.clusterlane-serp__review.is-stale{background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.12);color:#94a3b8}.clusterlane-serp__review--err{background:rgba(220,38,38,.16);border-color:rgba(252,165,165,.35);color:#fecaca}.clusterlane-serp__rv-tag{display:inline-flex;align-items:center;align-self:center;font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#c4b5fd}.clusterlane-serp__rv-tag .clusterlane-g{width:12px;height:12px;margin-right:4px}.clusterlane-serp__rv b{font-weight:700;color:#86efac}.clusterlane-serp__rv--warn b{color:#fcd34d}.clusterlane-serp__rv--bad b{color:#fca5a5}.clusterlane-serp__rv-stale{font-style:italic}.clusterlane-serp__rv-pro{margin:8px 0 0}.clusterlane-serp__orig{margin-top:8px;font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#94a3b8}.clusterlane-serp__orig .clusterlane-g{width:13px;height:13px;margin-right:4px;vertical-align:-2px;color:#86efac}.clusterlane-serp__orig .clusterlane-seo-ai__link{margin-left:4px}.clusterlane-seot__origchip{background:#eff6ff;color:#1d4ed8;cursor:help}.clusterlane-seot__keep{display:flex;align-items:center;gap:6px;margin:0 0 10px;font-size:12.5px;color:#64748b}.clusterlane-seot__keep .clusterlane-g{width:14px;height:14px;margin:0;color:#16a34a;flex-shrink:0}.clusterlane-seo-ai[hidden]{display:none}.clusterlane-seo-ai{margin-top:10px;max-width:600px;padding:12px 14px 14px;border-radius:12px;background:rgba(15,23,42,.72);border:1px solid rgba(255,255,255,.12);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:var(--ih-text-hi,#f1f5f9);font-size:13px;line-height:1.45}.clusterlane-seo-ai .clusterlane-pro-example-banner{margin-bottom:10px}.clusterlane-seo-ai__head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.clusterlane-seo-ai__head strong{display:inline-flex;align-items:center;font-size:13px}.clusterlane-seo-ai__meta{font-size:11.5px;color:var(--ih-muted,#94a3b8)}.clusterlane-seo-ai__link{background:0 0;border:0;padding:0;color:#93c5fd;font:inherit;font-size:12px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center}.clusterlane-seo-ai__link:hover{color:#fff;text-decoration:underline}.clusterlane-seo-ai__head .clusterlane-seo-ai__link{margin-left:auto}.clusterlane-seo-ai__close{background:0 0;border:0;color:var(--ih-muted,#94a3b8);font-size:18px;line-height:1;cursor:pointer;padding:0 2px}.clusterlane-seo-ai__head .clusterlane-seo-ai__link+.clusterlane-seo-ai__close{margin-left:4px}.clusterlane-seo-ai__head .clusterlane-seo-ai__meta+.clusterlane-seo-ai__close{margin-left:auto}.clusterlane-seo-ai__close:hover{color:#fff}.clusterlane-seo-ai__status:empty{display:none}.clusterlane-seo-ai__status{margin-top:8px;padding:7px 10px;border-radius:8px;background:rgba(255,255,255,.06);font-size:12.5px}.clusterlane-seo-ai__status--ok{background:rgba(34,197,94,.14);color:#bbf7d0}.clusterlane-seo-ai__status--bad{background:rgba(239,68,68,.16);color:#fecaca}.clusterlane-seo-ai__status code{background:rgba(255,255,255,.1);color:inherit;padding:0 4px;border-radius:4px;font-size:11.5px}.clusterlane-seo-ai__status .clusterlane-g{color:#4ade80}.clusterlane-seo-ai__note{margin:8px 0 0;font-size:12.5px;color:#fcd34d}.clusterlane-seo-ai__note a{color:#93c5fd}.clusterlane-seo-ai__cards{display:grid;gap:10px;margin-top:10px}.clusterlane-seo-ai__card{padding:10px 12px;border-radius:10px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1)}.clusterlane-seo-ai__card.is-applied{border-color:rgba(74,222,128,.55);box-shadow:0 0 0 1px rgba(74,222,128,.25) inset}.clusterlane-seo-ai__lbl{display:flex;justify-content:space-between;align-items:center;margin:2px 0 4px;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--ih-muted,#94a3b8)}.clusterlane-seo-ai__lbl+input,.clusterlane-seo-ai__lbl+textarea{margin-bottom:6px}#clusterlane-ai-head .clusterlane-seo-ai__d,#clusterlane-ai-head .clusterlane-seo-ai__t{width:100%;box-sizing:border-box;margin:0;padding:6px 9px;border-radius:8px;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);color:#f8fafc;font:14px/1.45 Arial,Helvetica,sans-serif;box-shadow:none;resize:vertical}#clusterlane-ai-head .clusterlane-seo-ai__t{color:#99c3ff;font-size:16px;height:34px;min-height:0}#clusterlane-ai-head .clusterlane-seo-ai__d{color:#d1d5db}#clusterlane-ai-head .clusterlane-seo-ai__d:focus,#clusterlane-ai-head .clusterlane-seo-ai__t:focus{outline:0;border-color:var(--ih-node,#60a5fa);box-shadow:0 0 0 3px rgba(96,165,250,.2)}.clusterlane-seo-ai__count{font-size:11px;font-weight:600;letter-spacing:0;text-transform:none;padding:0 7px;border-radius:999px}.clusterlane-seo-ai__count--ok{background:rgba(34,197,94,.16);color:#86efac}.clusterlane-seo-ai__count--warn{background:rgba(245,158,11,.18);color:#fcd34d}.clusterlane-seo-ai__count--bad{background:rgba(239,68,68,.2);color:#fca5a5}.clusterlane-seo-ai__actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:2px}.clusterlane-seo-ai__btn{height:28px;padding:0 12px;border-radius:8px;border:1px solid rgba(255,255,255,.18);background:0 0;color:#e2e8f0;font:600 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer}.clusterlane-seo-ai__btn:hover{background:rgba(255,255,255,.08);color:#fff}.clusterlane-seo-ai__btn--primary{background:#fff;border-color:#fff;color:#16223a}.clusterlane-seo-ai__btn--primary:hover{background:#e2e8f0;border-color:#e2e8f0;color:#16223a}.clusterlane-seo-ai__btn:disabled{opacity:.6;cursor:default}.clusterlane-seo-ai__hint{font-size:11.5px;color:var(--ih-muted,#94a3b8)}.clusterlane-seo-ai__error,.clusterlane-seo-ai__loading{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--ih-text,#cbd5e1)}.clusterlane-seo-ai__loading .clusterlane-g{animation:clusterlaneSeoPulse 1.2s ease-in-out infinite;color:#93c5fd}.clusterlane-seo-ai__error{color:#fecaca;justify-content:space-between}@keyframes clusterlaneSeoPulse{0%,100%{opacity:.4}50%{opacity:1}}.clusterlane-seo-ai-settings{margin-top:14px;padding-top:12px;border-top:1px solid #f1f5f9}.clusterlane-seo-ai-head{display:flex;align-items:center;margin:0 0 4px;font-size:13.5px;font-weight:700;color:#0f172a}#clusterlane-set-seo .clusterlane-pro-note{margin-top:14px}@media (max-width:782px){.clusterlane-seo-ai{max-width:none}}.clusterlane-inspector-head__meta .clusterlane-pro-example-banner strong{color:inherit}@media (min-width:783px){#clusterlane-dash-graph-card>.clusterlane-site-analysis-header,#clusterlane-site-analysis-card>.clusterlane-site-analysis-header,#clusterlane-sm-card>.clusterlane-site-analysis-header{flex-wrap:nowrap}#clusterlane-dash-graph-card .clusterlane-site-analysis-title,#clusterlane-site-analysis-card .clusterlane-site-analysis-title,#clusterlane-sm-card .clusterlane-site-analysis-title{flex:1 1 auto;min-width:0}#clusterlane-dash-graph-card .clusterlane-dash-graph-controls,#clusterlane-site-analysis-card .clusterlane-site-analysis-actions,#clusterlane-sm-card .clusterlane-dash-graph-controls{margin-left:auto;flex:0 0 auto}}.clusterlane-sm-card .clusterlane-site-analysis-title strong{display:inline}.clusterlane-sm-card .clusterlane-site-analysis-sub{display:block}.clusterlane-ai-improve{margin-bottom:20px}.clusterlane-ai-improve__head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}.clusterlane-ai-improve__head .clusterlane-ai-section-title{margin:0;padding:0;border:0}.clusterlane-ai-improve__sum{font-size:12.5px;color:#64748b}.clusterlane-ai-improve__sum strong{color:#0f172a}.clusterlane-ai-improve__list{display:grid;gap:10px}.clusterlane-ai-improve__loading{margin:0;font-size:13px;color:#94a3b8}.clusterlane-ai-improve__done{margin:0;display:flex;align-items:center;gap:6px;font-size:13.5px;color:#15803d;font-weight:600}.clusterlane-ai-improve__done .clusterlane-g{color:#16a34a}.clusterlane-ai-improve__item{--imp:#94a3b8;padding:12px 14px 12px 16px;border:1px solid #e2e8f0;border-left:3px solid var(--imp);border-radius:12px;background:#fff}.clusterlane-ai-improve__item--high{--imp:#dc2626}.clusterlane-ai-improve__item--medium{--imp:#d97706}.clusterlane-ai-improve__item--low{--imp:#94a3b8}.clusterlane-ai-improve__item-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.clusterlane-ai-improve__item-head strong{font-size:14px;color:#0f172a}.clusterlane-ai-improve__node{width:10px;height:10px;border-radius:50%;background:var(--imp);box-shadow:0 0 0 3px color-mix(in srgb,var(--imp) 20%,transparent);flex-shrink:0}.clusterlane-ai-improve__first{font-size:10.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#fff;background:#16223a;border-radius:999px;padding:2px 8px}.clusterlane-ai-improve__why{margin:4px 0 0 18px;font-size:12.5px;color:#475569;line-height:1.5}.clusterlane-ai-improve__lead{margin:10px 0 6px 18px;font-size:12px;color:#64748b}.clusterlane-ai-improve__item .clusterlane-tm-group{margin:6px 0 0 18px}.clusterlane-ai-improve__more{margin:6px 0 0 18px;font-size:12px;color:#64748b}.clusterlane-ai-improve__actions{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0 18px}.clusterlane-ai-improve__btn{display:inline-flex;align-items:center;text-decoration:none}.clusterlane-ai-improve__btn .clusterlane-g{margin-right:5px}.clusterlane-ai-improve__tips{margin:6px 0 0 36px;padding:0;font-size:12.5px;color:#475569;line-height:1.55;list-style:disc}.clusterlane-ai-improve__tips li{margin:0 0 3px}.clusterlane-ai-improve__ai{margin-top:16px;padding-top:14px;border-top:1px solid #f1f5f9}.clusterlane-ai-improve__ai .clusterlane-ai-section-title{font-size:13.5px}.clusterlane-ai-details-head{margin:26px 0 12px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#94a3b8}.clusterlane-ls2-row--focus{box-shadow:0 0 0 2px #3b82f6;border-radius:10px}@media (max-width:782px){.clusterlane-ai-improve__actions,.clusterlane-ai-improve__item .clusterlane-tm-group,.clusterlane-ai-improve__lead,.clusterlane-ai-improve__more,.clusterlane-ai-improve__why{margin-left:0}.clusterlane-ai-improve__tips{margin-left:18px}}.clusterlane-ai-improve__ai .clusterlane-ai-analysis-header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.clusterlane-ai-improve__ai .clusterlane-ai-analysis-title-row{display:flex;align-items:center;gap:8px}.clusterlane-ai-improve__ai .clusterlane-ai-section-title{border:0;padding:0;margin:0}.clusterlane-ai-improve__ai .clusterlane-ai-badge{display:none}.clusterlane-ai-improve__ai>p{margin:4px 0 0!important}.clusterlane-ai-improve__ai .clusterlane-action-btn{height:32px;padding:0 14px;font-size:12.5px}.clusterlane-ai-vitals{display:flex;flex-wrap:wrap;align-items:center;gap:6px 18px;margin:0 0 16px;padding:12px 18px;border-radius:14px;background:#fff;border:1px solid #e2e8f0;box-shadow:0 1px 3px rgba(15,23,42,.05)}.clusterlane-ai-vitals[hidden]{display:none}.clusterlane-ai-vital{display:inline-flex;align-items:baseline;gap:5px;font-size:12.5px;color:#64748b;white-space:nowrap}.clusterlane-ai-vital strong{font-size:17px;font-weight:800;color:#0f172a;letter-spacing:-.01em}.clusterlane-ai-vital--good strong{color:#059669}.clusterlane-ai-vital--fair strong{color:#d97706}.clusterlane-ai-vital--weak strong{color:#dc2626}.clusterlane-ai-vital--role{align-items:center;gap:7px;white-space:normal}.clusterlane-ai-vital--role strong{font-size:12.5px;font-weight:700}.clusterlane-ai-vital--role em{font-style:normal;color:#94a3b8}.clusterlane-ai-vital-sep{width:1px;height:18px;background:#e2e8f0}.clusterlane-ai-graph-tabs{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 10px}.clusterlane-ai-graph-tabs[hidden]{display:none}.clusterlane-ai-graph-tabs button{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:999px;border:1px solid #e2e8f0;background:#fff;color:#334155;font:600 12.5px/1 inherit;cursor:pointer}.clusterlane-ai-graph-tabs button:hover{border-color:#cbd5e1;background:#f8fafc}.clusterlane-ai-graph-tabs button.is-active{background:#16223a;border-color:#16223a;color:#fff}.clusterlane-ai-graph-tabs button em{font-style:normal;font-weight:500;opacity:.7}.clusterlane-ai-graph-tabs button .clusterlane-g{margin-right:0}.clusterlane-ai-graph-tab-node{width:8px;height:8px;border-radius:50%;background:#94a3b8}.clusterlane-ai-graph-tab-node--good{background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2)}.clusterlane-ai-graph-tab-node--fair{background:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.2)}.clusterlane-ai-graph-tab-node--weak{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.2)}.clusterlane-ai-graph-tip{background:#1e2535;color:#e2e8f0;border-radius:7px;padding:7px 10px;font-size:12px;line-height:1.4;max-width:280px;box-shadow:0 4px 14px rgba(0,0,0,.3)}.clusterlane-ai-graph-tip strong{display:block;color:#fff}.clusterlane-ai-graph-tip span{color:#94a3b8}.clusterlane-ai-graph-ci{margin-left:auto;font-size:12px;font-weight:600;color:#2563eb;text-decoration:none}.clusterlane-ai-graph-ci:hover{text-decoration:underline}@media (max-width:782px){.clusterlane-ai-vital-sep{display:none}.clusterlane-ai-vitals{gap:6px 14px}}#clusterlane-ai-inspector .clusterlane-ai-graph-card{margin-top:0}.clusterlane-ai-trio{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:16px;align-items:stretch}.clusterlane-ai-trio__card{margin:0;min-width:0}@media (max-width:1100px){.clusterlane-ai-trio{grid-template-columns:1fr 1fr}.clusterlane-ai-trio__card:last-child{grid-column:1/-1}}@media (max-width:782px){.clusterlane-ai-trio{grid-template-columns:1fr}}.clusterlane-ai-facts{display:grid;gap:0}.clusterlane-ai-fact{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid #f1f5f9;font-size:13px}.clusterlane-ai-fact:last-child{border-bottom:0}.clusterlane-ai-fact__label{color:#64748b}.clusterlane-ai-fact__value{font-weight:700;color:#0f172a;display:inline-flex;align-items:center;gap:6px;text-align:right}.clusterlane-ai-fact__value em{font-style:normal;font-weight:500;color:#94a3b8;font-size:12px}.clusterlane-ai-links-summary:empty{display:none}.clusterlane-ai-links-terms{margin:0 0 10px;font-size:12px;color:#64748b;line-height:1.5}.clusterlane-ai-trio__card .clusterlane-ai-links-details:first-of-type{margin-top:2px}.clusterlane-ai-cluster-row--stacked{display:block}.clusterlane-ai-cluster-line{display:flex;align-items:center;gap:7px;min-width:0}.clusterlane-ai-cluster-line .clusterlane-ai-cluster-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clusterlane-ai-cluster-line--meta{margin:5px 0 0 24px;flex-wrap:wrap;gap:6px}.clusterlane-thumb{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden;border-radius:6px;background:#e2e8f0;vertical-align:middle}.clusterlane-thumb img{width:100%;height:100%;object-fit:cover;display:block}.clusterlane-thumb--xs{width:22px;height:22px;border-radius:5px;margin-right:6px}.clusterlane-thumb--sm{width:28px;height:28px;margin-right:8px}.clusterlane-thumb--md{width:36px;height:36px;border-radius:8px;margin-right:10px}.clusterlane-thumb--none{color:#475569;font:700 11px/1 system-ui,sans-serif;background:#f1f5f9;box-shadow:inset 0 0 0 1px #e2e8f0}.clusterlane-thumb--md.clusterlane-thumb--none{font-size:14px}.clusterlane-thumb--pillar{background:#fdeadf;color:#b4491a;box-shadow:inset 0 0 0 1px #f5c3a6}.clusterlane-thumb--support{background:#fdf4d4;color:#86600a;box-shadow:inset 0 0 0 1px #f5dc8a}.clusterlane-thumb--light{background:#edf4e4;color:#4a6b2a;box-shadow:inset 0 0 0 1px #c8dcb0}.clusterlane-thumb--orphan{background:#e4f0f8;color:#245f84;box-shadow:inset 0 0 0 1px #b5d6ea}.clusterlane-browse__titlecell{display:flex;align-items:center;min-width:0}.clusterlane-browse__titlecell>div{min-width:0}.clusterlane-ls-result-item{display:flex;align-items:center}.clusterlane-ai-recent-item .clusterlane-thumb{margin-right:8px}.clusterlane-ai-link-list li{display:flex;align-items:center}.clusterlane-tm-in,.clusterlane-tm-target{align-items:center}.clusterlane-tm-in .clusterlane-thumb,.clusterlane-tm-target .clusterlane-thumb{vertical-align:-6px}.clusterlane-tm-group-src{display:inline-flex;align-items:center;gap:5px}.clusterlane-ls2-pick .clusterlane-thumb{align-self:center}.clusterlane-stickybar__dropdown-item{display:flex!important;align-items:center}.clusterlane-stickybar__dropdown-title{min-width:0;overflow:hidden;text-overflow:ellipsis}.clusterlane-graph-tip-img{display:block;width:100%;max-width:220px;height:92px;object-fit:cover;border-radius:5px;margin-bottom:6px}.clusterlane-ai-graph-tip{max-width:240px}.clusterlane-ai-vital-note{border-bottom:1px dotted #94a3b8;cursor:help}.clusterlane-stickybar__dropdown-item{display:flex;align-items:center;gap:10px;text-decoration:none!important}.clusterlane-stickybar__dropdown-item .clusterlane-thumb{margin-right:0;width:40px;height:40px;border-radius:7px}.clusterlane-stickybar__dropdown-item .clusterlane-stickybar__dropdown-title{text-decoration:none;line-height:1.35;color:#0f172a;font-weight:500}.clusterlane-stickybar__dropdown-item:focus-visible .clusterlane-stickybar__dropdown-title,.clusterlane-stickybar__dropdown-item:hover .clusterlane-stickybar__dropdown-title{color:#1d4ed8}.clusterlane-ai-link-list li{gap:6px;padding:5px 4px}.clusterlane-ai-link-list a.clusterlane-ai-linkrow{display:flex;align-items:center;gap:10px;flex:1 1 auto;min-width:0;color:#0f172a;text-decoration:none;border-radius:6px;padding:2px}.clusterlane-ai-link-list a.clusterlane-ai-linkrow .clusterlane-thumb{margin-right:0;width:36px;height:36px}.clusterlane-ai-linkrow__title{font-size:13px;font-weight:500;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clusterlane-ai-link-list a.clusterlane-ai-linkrow:focus-visible,.clusterlane-ai-link-list a.clusterlane-ai-linkrow:hover{text-decoration:none}.clusterlane-ai-link-list a.clusterlane-ai-linkrow:focus-visible .clusterlane-ai-linkrow__title,.clusterlane-ai-link-list a.clusterlane-ai-linkrow:hover .clusterlane-ai-linkrow__title{color:#1d4ed8}.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:6px;color:#94a3b8;text-decoration:none;transform:rotate(-45deg);opacity:0;transition:opacity .12s,color .12s,background .12s}.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site:focus-visible,.clusterlane-ai-link-list li:hover a.clusterlane-ai-linkrow__site{opacity:1}.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site:hover{color:#1d4ed8;background:#eff6ff;text-decoration:none}.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site .clusterlane-g{margin:0}@media (hover:none){.clusterlane-ai-link-list a.clusterlane-ai-linkrow__site{opacity:1}}.clusterlane-aic__table{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px}.clusterlane-aic__table th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#94a3b8;padding:6px 8px;border-bottom:1px solid #e2e8f0}.clusterlane-aic__table td{padding:8px;border-bottom:1px solid #f1f5f9;color:#334155}.clusterlane-aic__table td.clusterlane-aic__n{font-weight:700;color:#0f172a}.clusterlane-aic__table td.clusterlane-aic__act{text-align:right}.clusterlane-aic__table tr.is-cleared td{color:#94a3b8}.clusterlane-aic__foot{display:flex;align-items:center;gap:12px;margin:12px 0 0}.clusterlane-aic__status{font-size:12.5px;color:#15803d;font-weight:600}.clusterlane-aic__empty{margin:4px 0 0;color:#64748b}.clusterlane-ci-chosen{display:inline-block;margin-left:4px;padding:0 7px;border-radius:999px;background:#fff7ed;color:#c2410c;font-size:10.5px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;line-height:18px;vertical-align:middle}.clusterlane-ci-pillar-btn{opacity:.55}.clusterlane-ci-member-row:hover .clusterlane-ci-pillar-btn,.clusterlane-ci-pillar-btn:focus-visible{opacity:1}.clusterlane-ci-pillar-btn.is-chosen{opacity:1;color:#c2410c;border-color:#fdba74;background:#fff7ed}.clusterlane-archetype-mixed{background:#eef2f7;color:#475569}.clusterlane-archetype-unclassified{background:#f8fafc;color:#94a3b8;border:1px dashed #cbd5e1}.clusterlane-sa-go-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}a.clusterlane-sa-go{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;background:#eff6ff;border:1px solid #bfdbfe;color:#1d4ed8;font-size:12px;font-weight:600;text-decoration:none;line-height:1.3}a.clusterlane-sa-go:focus-visible,a.clusterlane-sa-go:hover{background:#dbeafe;color:#1e40af}a.clusterlane-sa-go .clusterlane-g{margin:0;width:13px;height:13px}@keyframes clusterlaneFocusFlash{0%{box-shadow:0 0 0 0 rgba(59,130,246,.55)}100%{box-shadow:0 0 0 14px rgba(59,130,246,0)}}.clusterlane-focus-flash{animation:clusterlaneFocusFlash 1.1s ease-out 2;border-radius:12px}.clusterlane-sa-go.is-example{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;background:#f8fafc;border:1px dashed #cbd5e1;color:#64748b;font-size:12px;font-weight:600;line-height:1.3;cursor:default}.clusterlane-sa-go.is-example .clusterlane-g{margin:0;width:13px;height:13px}.clusterlane-ca-unscanned{display:flex;align-items:center;gap:5px;grid-column:2/-1;margin:-4px 0 0;font-size:12px;color:#b45309}.clusterlane-ca-unscanned .clusterlane-g{color:#d97706;margin:0}.clusterlane-rp{--clusterlane-rp-ink:#16223a;--clusterlane-rp-pillar:#e76c2e;--clusterlane-rp-support:#f5c842;--clusterlane-rp-light:#90b865;--clusterlane-rp-orphan:#5ba4cf}.clusterlane-rp *{-webkit-print-color-adjust:exact;print-color-adjust:exact}.clusterlane-rp-head{margin-top:16px;padding:30px 34px;border-radius:18px;background:var(--clusterlane-rp-ink);color:#fff}.clusterlane-rp-head__top{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;flex-wrap:wrap}.clusterlane-rp-head__title{display:flex;align-items:center;gap:16px;min-width:0}.clusterlane-rp-head__mark{display:inline-flex;flex-shrink:0}.clusterlane-rp-head h1{margin:2px 0 4px!important;padding:0!important;color:#fff!important;font-size:30px!important;font-weight:800!important;line-height:1.15!important;letter-spacing:-.01em}.clusterlane-rp-eyebrow{font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#9fb0c8}.clusterlane-rp-head__sub{font-size:13.5px;color:#cbd5e1}.clusterlane-rp-head__actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.clusterlane-rp-periods{display:inline-flex;gap:2px;padding:3px;border-radius:10px;background:rgba(255,255,255,.08)}.clusterlane-rp-periods a{display:inline-flex;align-items:center;height:34px;padding:0 12px;border-radius:8px;color:#9fb0c8;font-size:13px;font-weight:600;text-decoration:none}.clusterlane-rp-periods a:hover{color:#fff}.clusterlane-rp-periods a.is-active{background:rgba(255,255,255,.16);color:#fff}.clusterlane-rp-periods a:focus-visible,.clusterlane-rp-print:focus-visible{outline:2px solid #60a5fa;outline-offset:2px;box-shadow:none}.clusterlane-rp-print{display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 16px;border:0;border-radius:9px;background:#fff;color:var(--clusterlane-rp-ink);font-size:13px;font-weight:700;cursor:pointer}.clusterlane-rp-print:hover{background:#e2e8f0}.clusterlane-rp-print .clusterlane-g{margin:0}.clusterlane-rp-kpis{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-top:24px}.clusterlane-rp-kpi{padding:14px 16px;border-radius:12px;background:rgba(255,255,255,.06);min-width:0}.clusterlane-rp-kpi__label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9fb0c8}.clusterlane-rp-kpi__value{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 10px;margin:6px 0 4px;font-size:30px;font-weight:800;line-height:1.1}.clusterlane-rp-kpi__sub{font-size:12px;color:#9fb0c8}.clusterlane-rp-delta{font-size:13px;font-weight:700;white-space:nowrap}.clusterlane-rp-delta--good{color:#86efac}.clusterlane-rp-delta--bad{color:#fca5a5}.clusterlane-rp-delta--flat{color:#9fb0c8}.clusterlane-rp-grid{display:grid;gap:20px;margin-top:20px}.clusterlane-rp-grid--trend{grid-template-columns:minmax(0,1.9fr) minmax(0,1fr)}.clusterlane-rp-grid--two{grid-template-columns:repeat(2,minmax(0,1fr))}.clusterlane-rp-card{margin-top:20px;padding:22px 24px;border:1px solid #e2e8f0;border-radius:14px;background:#fff;min-width:0;box-sizing:border-box}.clusterlane-rp-grid>.clusterlane-rp-card{margin-top:0}.clusterlane-rp-card__head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:12px}.clusterlane-rp-card__head h2{margin:0;padding:0;font-size:16px;font-weight:700;color:#0f172a}.clusterlane-rp-card__meta{font-size:12.5px;color:#64748b}.clusterlane-rp-note{margin:8px 0 0;font-size:12.5px;color:#64748b}.clusterlane-rp-empty{margin:0;font-size:14px;color:#475569}.clusterlane-rp-legend{display:flex;flex-wrap:wrap;gap:6px 16px;font-size:12.5px;color:#475569}.clusterlane-rp-legend span{display:inline-flex;align-items:center;gap:6px}.clusterlane-rp-key{display:inline-block;width:16px}.clusterlane-rp-key--line{height:3px;border-radius:2px;background:var(--clusterlane-rp-ink)}.clusterlane-rp-key--dash{height:0;border-top:2px dashed var(--clusterlane-rp-orphan)}.clusterlane-rp-chart svg{display:block;width:100%;height:auto}.clusterlane-rp-chart text{font-size:12px;fill:#64748b}.clusterlane-rp-grid-lines line{stroke:#eef0f3;stroke-width:1}.clusterlane-rp-area{fill:var(--clusterlane-rp-ink);fill-opacity:.06}.clusterlane-rp-line-health{fill:none;stroke:var(--clusterlane-rp-ink);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.clusterlane-rp-line-orphans{fill:none;stroke:var(--clusterlane-rp-orphan);stroke-width:2;stroke-dasharray:5 4}.clusterlane-rp-dot-health{fill:var(--clusterlane-rp-ink);stroke:#fff;stroke-width:2}.clusterlane-rp-dot-orphans{fill:var(--clusterlane-rp-orphan);stroke:#fff;stroke-width:2}.clusterlane-rp-chart .clusterlane-rp-end-health{font-size:13px;font-weight:700;fill:var(--clusterlane-rp-ink)}.clusterlane-rp-chart .clusterlane-rp-end-orphans{font-weight:700;fill:#245f84}.clusterlane-rp-version{stroke:#94a3b8;stroke-width:1;stroke-dasharray:2 3}.clusterlane-rp-chart .clusterlane-rp-version-label{font-size:11px}.clusterlane-rp-building{display:flex;align-items:center;gap:14px;padding:26px 22px;border:1px dashed #cbd5e1;border-radius:12px;background:#f8fafc;color:#475569}.clusterlane-rp-building .clusterlane-g{width:24px;height:24px;flex-shrink:0;color:#94a3b8;margin:0}.clusterlane-rp-building p{margin:0;font-size:13.5px;line-height:1.55}.clusterlane-rp-work__list{margin:0}.clusterlane-rp-work__row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:9px 0;border-bottom:1px solid #f1f5f9}.clusterlane-rp-work__row dt{font-size:14px;color:#334155}.clusterlane-rp-work__row dd{margin:0;font-size:18px;font-weight:800;color:#0f172a;font-variant-numeric:tabular-nums}.clusterlane-rp-work__row--main dd{font-size:22px}.clusterlane-rp-work__row--sub{padding:5px 0 5px 12px;border-bottom:0}.clusterlane-rp-work__row--sub dt{font-size:13px;color:#64748b}.clusterlane-rp-work__row--sub dd{font-size:13px;font-weight:600;color:#334155}.clusterlane-rp-work__row--sub+.clusterlane-rp-work__row:not(.clusterlane-rp-work__row--sub){border-top:1px solid #f1f5f9}.clusterlane-rp-mini-h{margin:14px 0 6px;font-size:12px;font-weight:600;color:#64748b}.clusterlane-rp-work__bars svg{display:block;width:100%;max-width:340px;height:auto}.clusterlane-rp-bar{fill:var(--clusterlane-rp-ink)}.clusterlane-rp-bars-base{stroke:#e2e8f0}.clusterlane-rp-work__bars text{font-size:11px;fill:#64748b}.clusterlane-rp-work__bars .clusterlane-rp-bar-max{font-weight:700;fill:var(--clusterlane-rp-ink)}.clusterlane-rp-table-wrap{border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}.clusterlane-rp-table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:13.5px}.clusterlane-rp-table th{padding:10px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;text-align:left;font-size:12px;font-weight:600;color:#64748b}.clusterlane-rp-table td{padding:11px 14px;border-bottom:1px solid #f1f5f9;color:#334155;vertical-align:middle;overflow-wrap:anywhere}.clusterlane-rp-table tbody:last-child tr:last-child td{border-bottom:0}.clusterlane-rp-table .num{text-align:right;font-variant-numeric:tabular-nums}.clusterlane-rp-table th:nth-child(2){width:84px}.clusterlane-rp-table th:nth-child(3){width:200px}.clusterlane-rp-table th:nth-child(4){width:84px}.clusterlane-rp-table th:nth-child(5){width:84px}.clusterlane-rp-table th:nth-child(6){width:96px}.clusterlane-rp-cl-name{font-weight:700;color:#0f172a;text-decoration:none}.clusterlane-rp-cl-name:hover{color:#b4491a;text-decoration:underline}.clusterlane-rp-cl-arch{display:block;margin-top:2px;font-size:12px;color:#64748b}.clusterlane-rp-mix{display:flex;gap:1px;width:100%;max-width:180px;height:10px;border-radius:5px;overflow:hidden;background:#f1f5f9}.clusterlane-rp-mix__seg{flex-basis:0;min-width:3px}.clusterlane-rp-mix__seg--pillar{background:var(--clusterlane-rp-pillar)}.clusterlane-rp-mix__seg--support{background:var(--clusterlane-rp-support)}.clusterlane-rp-mix__seg--light{background:var(--clusterlane-rp-light)}.clusterlane-rp-mix__seg--orphan{background:var(--clusterlane-rp-orphan)}.clusterlane-rp-health{font-weight:700}.clusterlane-rp-health--good{color:#15803d}.clusterlane-rp-health--fair{color:#b45309}.clusterlane-rp-health--weak{color:#dc2626}.clusterlane-rp-health--none{color:#94a3b8}.clusterlane-rp-table .clusterlane-rp-delta--good{color:#15803d}.clusterlane-rp-table .clusterlane-rp-delta--bad{color:#dc2626}.clusterlane-rp-table .clusterlane-rp-delta--flat{color:#94a3b8;font-weight:600}.clusterlane-rp-bad{color:#dc2626;font-weight:700}.clusterlane-rp-muted{color:#94a3b8}.clusterlane-rp-table-foot{display:flex;justify-content:space-between;align-items:center;gap:10px 16px;flex-wrap:wrap;margin-top:12px}.clusterlane-rp-legend--roles{margin-left:auto}.clusterlane-rp-link{padding:0;border:0;background:0 0;color:var(--clusterlane-rp-ink);font-size:13px;font-weight:600;text-decoration:underline;cursor:pointer}.clusterlane-rp-link:hover{color:#b4491a}.clusterlane-rp-link:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.clusterlane-rp-comp{display:grid;grid-template-columns:210px minmax(0,1fr) 280px;gap:14px 22px;align-items:center}.clusterlane-rp-comp__label strong{display:block;font-size:14px;color:#0f172a}.clusterlane-rp-comp__label span{font-size:12px;color:#64748b}.clusterlane-rp-comp__bar{display:flex;gap:2px;height:18px;border-radius:6px;overflow:hidden;background:#f1f5f9}.clusterlane-rp-comp__bar span{flex-basis:0;min-width:3px}.clusterlane-rp-comp__legend{display:flex;flex-wrap:wrap;gap:3px 12px;font-size:12.5px;color:#475569}.clusterlane-rp-comp__legend span{display:inline-flex;align-items:center;gap:5px}.clusterlane-rp-comp__legend i{display:inline-block;width:9px;height:9px;border-radius:50%}.clusterlane-rp-comp__legend b{font-weight:700;color:#0f172a}.clusterlane-rp-comp__legend em{font-style:normal;color:#94a3b8}.clusterlane-rp-todo{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:14px}.clusterlane-rp-todo li{display:flex;gap:12px;align-items:flex-start;margin:0}.clusterlane-rp-todo__n{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:var(--clusterlane-rp-ink);color:#fff;font-size:12px;font-weight:700}.clusterlane-rp-todo__title{font-size:14px;font-weight:600;color:#0f172a;text-decoration:none}.clusterlane-rp-todo__title:hover{text-decoration:underline}.clusterlane-rp-todo__detail{margin-top:2px;font-size:13px;color:#64748b;line-height:1.5}.clusterlane-rp-todo__detail a{color:#475569}.clusterlane-rp-done{display:flex;gap:8px;align-items:flex-start;margin:0;font-size:14px;color:#15803d}.clusterlane-rp-ai-badge{padding:2px 8px;border-radius:999px;background:var(--clusterlane-rp-ink);color:#fff;font-size:10.5px;font-weight:700;letter-spacing:.05em}.clusterlane-rp-summary .clusterlane-rp-card__head{justify-content:flex-start}.clusterlane-rp-summary__body p{margin:0 0 10px;font-size:14px;line-height:1.65;color:#334155}.clusterlane-rp-summary__body p.clusterlane-rp-note{font-size:12.5px;color:#64748b}.clusterlane-rp-summary__actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:6px}.clusterlane-rp-error{color:#b32d2e}.clusterlane-rp-foot{display:flex;justify-content:space-between;gap:8px 16px;flex-wrap:wrap;margin:20px 4px 0;font-size:12px;color:#64748b}.clusterlane-rp-foot a{color:#475569}@media (max-width:1100px){.clusterlane-rp-kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.clusterlane-rp-grid--trend{grid-template-columns:minmax(0,1fr)}.clusterlane-rp-comp{grid-template-columns:180px minmax(0,1fr)}.clusterlane-rp-comp__legend{grid-column:2}}@media (max-width:782px){.clusterlane-rp-head{padding:22px 18px}.clusterlane-rp-head h1{font-size:24px!important}.clusterlane-rp-kpis{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.clusterlane-rp-kpi__value{font-size:24px}.clusterlane-rp-grid--two{grid-template-columns:minmax(0,1fr)}.clusterlane-rp-card{padding:18px 16px}.clusterlane-rp-comp{grid-template-columns:minmax(0,1fr);gap:6px}.clusterlane-rp-comp__legend{grid-column:auto;margin-bottom:10px}.clusterlane-rp-table{table-layout:auto}.clusterlane-rp-table td,.clusterlane-rp-table th{padding:10px 8px}.clusterlane-rp-table .clusterlane-rp-col-change,.clusterlane-rp-table .clusterlane-rp-col-mix{display:none}.clusterlane-rp-table th:not(:first-child){width:1%;white-space:nowrap}}@media print{#adminmenumain,#screen-meta-links,#wpadminbar,#wpfooter,.clusterlane-pro-example-banner,.clusterlane-rp-periods,.clusterlane-rp-print,.clusterlane-rp-summary:not(.has-text),.clusterlane-rp-summary__actions,.clusterlane-stickybar,.notice,.update-nag,[data-clusterlane-more]{display:none!important}html.wp-toolbar{padding-top:0!important}#wpbody-content,#wpcontent{margin:0!important;padding:0!important}#wpwrap,body{background:#fff!important}.clusterlane-wrap.clusterlane-rp{max-width:none;margin:0}.clusterlane-rp-more{display:table-row-group!important}.clusterlane-rp-card:not(:has(.clusterlane-rp-table)),.clusterlane-rp-head{break-inside:avoid}.clusterlane-rp-card{border-color:#cbd5e1}.clusterlane-rp-head{padding:22px 24px}.clusterlane-rp-kpis{grid-template-columns:repeat(5,minmax(0,1fr))!important;gap:8px}.clusterlane-rp-kpi{padding:10px 12px}.clusterlane-rp-kpi__value{font-size:22px!important}.clusterlane-rp-kpi__label{font-size:10px}.clusterlane-rp-grid--trend{grid-template-columns:minmax(0,1fr)!important}.clusterlane-rp-grid--two{grid-template-columns:repeat(2,minmax(0,1fr))!important}.clusterlane-rp-grid--two:not(:has(.clusterlane-rp-summary.has-text)){grid-template-columns:minmax(0,1fr)!important}.clusterlane-rp-comp{grid-template-columns:150px minmax(0,1fr)!important;gap:6px 16px}.clusterlane-rp-comp__legend{grid-column:2!important;margin-bottom:8px}.clusterlane-rp-table{table-layout:auto!important;font-size:12px}.clusterlane-rp-table td,.clusterlane-rp-table th{padding:7px 8px!important}.clusterlane-rp-table th:not(:first-child){width:1%!important;white-space:nowrap}.clusterlane-rp-table .clusterlane-rp-col-change,.clusterlane-rp-table .clusterlane-rp-col-mix{display:table-cell!important}.clusterlane-rp-table .clusterlane-rp-mix{width:110px}.clusterlane-rp-table tr{break-inside:avoid}.clusterlane-rp-card{padding:16px 18px}.clusterlane-rp a{text-decoration:none;color:inherit}@page{margin:12mm}}.clusterlane-rp-example-note{margin-top:16px}.clusterlane-rp-example-note .clusterlane-pro-example-banner{margin:0}.clusterlane-rp-print:disabled{opacity:.45;cursor:not-allowed}.clusterlane-rp--example .clusterlane-rp-cl-name,.clusterlane-rp--example .clusterlane-rp-todo__title{cursor:default}.clusterlane-title-preview{margin:8px 0 0;padding:7px 10px;max-width:640px;background:#f6f7fb;border:1px solid #e3e6ee;border-radius:6px;font-size:12.5px;line-height:1.5;color:#3c434a;overflow-wrap:anywhere}.clusterlane-title-preview__label{color:#646970}.clusterlane-title-preview__from,.clusterlane-title-preview__same{color:#3c434a}.clusterlane-title-preview del{color:#a0a5aa;text-decoration-color:rgba(160,165,170,.7)}.clusterlane-title-preview__arrow{color:#8c8f94}.clusterlane-title-preview strong{color:#16223a}.clusterlane-title-preview__note{display:block;margin-top:2px;color:#646970;font-size:12px}.clusterlane-access-table{border-collapse:collapse;width:100%;max-width:640px;font-size:13px}.clusterlane-access-table td,.clusterlane-access-table th{padding:8px 10px;border-bottom:1px solid #eef0f4;text-align:center;vertical-align:middle}.clusterlane-access-table td:first-child,.clusterlane-access-table th:first-child{text-align:left}.clusterlane-access-table thead th{font-weight:600;color:#3c434a;font-size:12px;border-bottom-color:#dcdfe6}.clusterlane-access-table tr.is-fixed td{color:#646970}.clusterlane-access-note{color:#8c8f94;font-size:12px}@media (max-width:600px){.clusterlane-access-table td,.clusterlane-access-table th{padding:8px 6px}.clusterlane-access-table thead th{font-size:11px}}.clusterlane-no-right{margin:0 0 10px;padding:8px 12px;background:#f6f7fb;border:1px solid #e3e6ee;border-left:3px solid #8c8f94;border-radius:6px;font-size:12.5px;line-height:1.5;color:#3c434a}.clusterlane-lt-custom-note{margin:0 0 10px;padding:7px 10px;background:#fff8e5;border:1px solid #f0d99a;border-radius:6px;font-size:12.5px;line-height:1.5;color:#3c434a}.clusterlane-lt-use-controls{padding:0;border:0;background:0 0;color:#2271b1;text-decoration:underline;cursor:pointer;font:inherit}.clusterlane-lt-row.is-custom-html .clusterlane-lt-checkbox-label,.clusterlane-lt-row.is-custom-html .clusterlane-lt-control-row{opacity:.5}.clusterlane-pillar-preview{margin-top:10px;max-width:560px;padding:12px 14px;background:#fff;border:1px dashed #dcdfe6;border-radius:6px;font-size:14px;line-height:1.55;color:#1e1e1e}.clusterlane-pillar-preview blockquote,.clusterlane-pillar-preview p{margin:0}.clusterlane-pillar-status p{margin:0 0 6px}.clusterlane-auto-pill{flex:none;margin-left:6px;padding:1px 7px;border-radius:999px;background:#eef2f7;color:#475569;font-size:11px;font-weight:500;white-space:nowrap}.clusterlane-pillar-breakdown{border-collapse:collapse;width:100%;max-width:760px;margin:6px 0 8px;font-size:13px}.clusterlane-pillar-breakdown td,.clusterlane-pillar-breakdown th{padding:7px 8px;border-bottom:1px solid #eef0f4;text-align:left;vertical-align:top}.clusterlane-pillar-breakdown thead th{font-size:12px;font-weight:600;color:#3c434a;border-bottom-color:#dcdfe6}.clusterlane-pillar-breakdown .num{white-space:nowrap}.clusterlane-pillar-breakdown a{font-size:12px}.clusterlane-pillar-warn{color:#b32d2e;font-size:12px}.clusterlane-pillar-breakdown td:nth-child(2),.clusterlane-pillar-breakdown td:nth-child(3),.clusterlane-pillar-breakdown th:nth-child(2),.clusterlane-pillar-breakdown th:nth-child(3){width:1%;white-space:nowrap;text-align:center;padding-left:14px;padding-right:14px}.clusterlane-pillar-breakdown td:first-child{width:42%}@media (max-width:600px){.clusterlane-pillar-breakdown th:nth-child(2),.clusterlane-pillar-breakdown th:nth-child(3){white-space:normal}.clusterlane-pillar-breakdown td:first-child{width:auto}}.clusterlane-ci-pillar-notice{display:flex;align-items:flex-start;gap:8px;margin:0 0 12px;padding:9px 12px;background:#fff7ed;border:1px solid #fed7aa;border-left:3px solid #e76c2e;border-radius:6px;font-size:13px;line-height:1.5;color:#3c434a}.clusterlane-ci-pillar-notice svg{flex:none;width:16px;height:16px;margin-top:2px;color:#e76c2e}.clusterlane-ci-pillar-notice span{flex:1}.clusterlane-ci-pillar-notice__close{flex:none;padding:0 4px;border:0;background:0 0;color:#8c8f94;font-size:18px;line-height:1;cursor:pointer}.clusterlane-ci-pillar-notice__close:hover{color:#3c434a}.clusterlane-improve-count--split{background:#ede9fe;color:#5b21b6}.clusterlane-improve-count--pro{background:#f5f3ff;color:#6d28d9;border:1px dashed #c4b5fd}.clusterlane-split-head{display:flex;align-items:flex-start;gap:12px;margin:0 0 12px}.clusterlane-split-note{flex:1;display:flex;gap:8px;margin:0;font-size:13.5px;line-height:1.5;color:#1d2327}.clusterlane-split-note svg{flex:none;width:16px;height:16px;margin-top:2px;color:#16223a}.clusterlane-split-meta{font-size:12px;color:#8c8f94;white-space:nowrap;margin-top:2px}.clusterlane-split-regen{flex-shrink:0;height:28px;padding:0 10px;gap:5px;margin-top:-3px}.clusterlane-split-regen .clusterlane-g{width:13px;height:13px;margin:0;color:#7c3aed}.clusterlane-split-stale{margin:0 0 12px;padding:7px 10px;background:#fffbeb;border:1px solid #f0d99a;border-radius:6px;font-size:12.5px}.clusterlane-split-error{color:#b32d2e}.clusterlane-split-top{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:14px;margin-bottom:14px}.clusterlane-split-card,.clusterlane-split-graphbox,.clusterlane-split-side>div{border:1px solid #e3e6ee;border-radius:10px;background:#fff}.clusterlane-split-graphbox{padding:12px 14px;display:flex;flex-direction:column;gap:8px}.clusterlane-split-graph-title,.clusterlane-split-ready-title{font-size:13.5px;font-weight:600;color:#1d2327}.clusterlane-split-graph{height:300px;border-radius:8px;background:#f8fafc}.clusterlane-split-legend{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;color:#3c434a}.clusterlane-split-legend>span{display:inline-flex;align-items:center;gap:6px}.clusterlane-split-hint{margin:0;font-size:11.5px;color:#8c8f94}.clusterlane-split-dot{display:inline-block;flex:none;width:10px;height:10px;border-radius:50%;background:var(--c,#94a3b8)}.clusterlane-split-side{display:flex;flex-direction:column;gap:12px}.clusterlane-split-ready{padding:12px 14px;display:flex;flex-direction:column;gap:12px}.clusterlane-split-ready-row{display:flex;flex-direction:column;gap:5px}.clusterlane-split-ready-top{display:flex;align-items:center;gap:8px;font-size:13px;flex-wrap:wrap}.clusterlane-split-ready-top>span:last-child{margin-left:auto;font-size:12px;color:#646970}.clusterlane-split-bar{height:6px;border-radius:3px;background:#eef0f4;overflow:hidden}.clusterlane-split-bar span{display:block;height:6px;background:var(--c)}.clusterlane-split-verdict{font-size:12px;color:#50575e}.clusterlane-split-overlap{margin:0;padding:10px 12px;border:1px solid #f0d99a!important;background:#fffbeb!important;border-radius:10px;font-size:12.5px;line-height:1.5;color:#3c434a}.clusterlane-split-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;align-items:start}.clusterlane-split-card{display:flex;flex-direction:column;overflow:hidden;border-top:4px solid var(--c)}.clusterlane-split-card-head{display:flex;align-items:center;gap:8px;padding:10px 12px 4px}.clusterlane-split-name{flex:1;min-width:0;font-size:14.5px;font-weight:600;color:#1d2327;border:1px solid transparent;border-radius:6px;padding:3px 6px;margin-left:-6px;background:#fff;box-shadow:none}.clusterlane-split-name:hover{border-color:#dcdfe6}.clusterlane-split-name:focus{border-color:#2271b1;outline:0;box-shadow:0 0 0 1px #2271b1}.clusterlane-split-stayname{flex:1;font-size:14.5px}.clusterlane-split-count{font-size:12px;color:#646970;white-space:nowrap}.clusterlane-split-why{margin:0;padding:0 12px 10px;font-size:12.5px;line-height:1.45;color:#50575e}.clusterlane-split-rows{border-top:1px solid #eef0f4}.clusterlane-split-row{display:flex;align-items:center;gap:8px;padding:5px 10px 5px 12px;border-bottom:1px solid #f3f4f7;min-height:36px}.clusterlane-split-title{flex:1;min-width:0;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clusterlane-split-tag{flex:none;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;padding:2px 7px;border-radius:999px;background:#f1f5f9;color:#475569}.clusterlane-split-tag--hub{background:#fff7ed;color:#9a3412}.clusterlane-split-row .clusterlane-ci-pillar-btn{opacity:1;flex:none;width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center}.clusterlane-split-row .clusterlane-ci-pillar-btn:not(.is-chosen){color:#a7aaad}.clusterlane-split-chips{display:inline-flex;gap:4px;flex:none}.clusterlane-split-chip{width:26px;height:26px;border-radius:50%;border:2px solid var(--c);background:#fff;color:var(--c);font-size:11px;font-weight:700;line-height:1;padding:0;cursor:pointer}.clusterlane-split-chip.is-on{background:var(--c);color:#fff}.clusterlane-split-chip:focus-visible{outline:2px solid #2271b1;outline-offset:1px}.clusterlane-split-empty{margin:0;padding:10px 12px;font-size:12.5px;color:#8c8f94}.clusterlane-split-create{display:flex;align-items:center;gap:8px;padding:9px 12px;font-size:12.5px;color:#3c434a;background:#fafbfc;margin-top:auto;cursor:pointer}.clusterlane-split-create strong{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clusterlane-split-card--stay .clusterlane-split-rows{max-height:420px;overflow-y:auto}.clusterlane-split-foot{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:14px;padding-top:12px;border-top:1px solid #e3e6ee}.clusterlane-split-foot-text{flex:1;min-width:200px;font-size:13px;color:#50575e}.clusterlane-split-foot-note{font-size:12.5px;color:#8c8f94}.clusterlane-split-done{flex:1;display:flex;gap:8px;align-items:flex-start;margin:0;padding:10px 12px;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:8px;font-size:13px;color:#065f46}.clusterlane-split-done svg{flex:none;width:16px;height:16px;margin-top:2px}@media (max-width:1100px){.clusterlane-split-cards,.clusterlane-split-top{grid-template-columns:minmax(0,1fr)}}.clusterlane-split-card input.clusterlane-split-name[type=text]{border:1px solid #e3e6ee;box-shadow:none;min-height:32px}.clusterlane-split-card input.clusterlane-split-name[type=text]:hover{border-color:#c3c4c7}.clusterlane-split-card input.clusterlane-split-name[type=text]:focus{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1}@media (max-width:600px){.clusterlane-split-head{flex-wrap:wrap}.clusterlane-split-tag{display:none}.clusterlane-split-graph{height:240px}.clusterlane-split-row{flex-wrap:wrap;row-gap:6px;padding:8px 10px}.clusterlane-split-title{flex-basis:calc(100% - 40px);white-space:normal}.clusterlane-split-row .clusterlane-ci-pillar-btn{margin-left:32px}.clusterlane-split-chips{margin-left:auto}.clusterlane-split-create{flex-wrap:wrap}.clusterlane-split-ready-top>span:last-child{margin-left:18px;width:100%}}.clusterlane-split-card.is-off{border-top-color:#dcdfe6;background:#fafbfc}.clusterlane-split-card.is-off>:not(.clusterlane-split-create){opacity:.42;filter:grayscale(1)}.clusterlane-split-card.is-off .clusterlane-split-create{background:#f0f2f5}.clusterlane-split-idea{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;margin:0 12px 10px;padding:9px 11px;background:#fffbeb;border:1px solid #f0d99a;border-radius:8px;font-size:12.5px;line-height:1.45;color:#3c434a}.clusterlane-split-idea>svg{flex:none;width:16px;height:16px;margin-top:1px;color:#b45309}.clusterlane-split-idea-text{flex:1;min-width:200px}.clusterlane-split-idea-text strong{font-weight:600;color:#1d2327}.clusterlane-split-idea-text small{display:block;margin-top:3px;font-size:11.5px;color:#646970}.clusterlane-split-idea-actions{display:flex;gap:6px;flex-wrap:wrap;margin-left:26px}.clusterlane-split-idea--cluster{margin:0 0 14px}.clusterlane-split-pick{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 14px;padding:9px 12px;background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;font-size:13px;line-height:1.45;color:#3c434a}.clusterlane-split-pick>svg{flex:none;width:16px;height:16px;color:#e76c2e}.clusterlane-split-pick>span{flex:1;min-width:200px}.clusterlane-split-pick-now{color:#646970;font-size:12px}.clusterlane-split-pick--ok{background:#f0fdf4;border-color:#bbf7d0}.clusterlane-split-pick--ok>svg{color:#15803d}.clusterlane-split-onetopic{display:flex;gap:8px;align-items:flex-start;margin:0;padding:10px 12px;background:#f6f7fb;border-radius:8px;font-size:13px;color:#3c434a}.clusterlane-split-onetopic svg{flex:none;width:16px;height:16px;margin-top:2px;color:#15803d}.clusterlane-review-card{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin:18px 0 26px}.clusterlane-review-card-icon{flex:none;width:40px;height:40px;border-radius:10px;background:#eef2f7;color:#16223a;display:flex;align-items:center;justify-content:center}.clusterlane-review-card-icon svg{width:20px;height:20px}.clusterlane-review-card-text{flex:1;min-width:240px}.clusterlane-review-card-text strong{font-size:14.5px;color:#1d2327}.clusterlane-review-card-text p{margin:3px 0 0;font-size:13px;color:#50575e}.clusterlane-review-dialog{width:min(1080px,calc(100vw - 32px));max-height:calc(100vh - 48px);padding:0;border:0;border-radius:14px;box-shadow:0 24px 60px rgba(15,23,42,.35);overflow:auto;color:#1d2327}.clusterlane-review-dialog::backdrop{background:rgba(15,23,42,.55)}.clusterlane-review-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;gap:12px;padding:16px 22px;background:#16223a;color:#fff}.clusterlane-review-head h2{margin:0;font-size:17px;color:#fff}.clusterlane-review-head p{margin:3px 0 0;font-size:12.5px;color:#cbd5e1}.clusterlane-review-head>div{flex:1}.clusterlane-review-close{flex:none;border:0;background:0 0;color:#cbd5e1;font-size:24px;line-height:1;cursor:pointer;padding:0 4px}.clusterlane-review-close:hover{color:#fff}.clusterlane-review-body{padding:16px 22px 22px}.clusterlane-review-summary{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:12px 14px;background:#f6f7fb;border:1px solid #e3e6ee;border-radius:10px;margin-bottom:12px}.clusterlane-review-summary>div:first-child{flex:1;min-width:240px;display:flex;flex-direction:column;gap:2px;font-size:13px;color:#50575e}.clusterlane-review-summary strong{font-size:14px;color:#1d2327}.clusterlane-review-run{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.clusterlane-review-cost{font-size:12.5px;color:#50575e}.clusterlane-review-alldone{display:inline-flex;gap:6px;align-items:center;font-size:13px;color:#15803d}.clusterlane-review-alldone svg{width:16px;height:16px}.clusterlane-review-progress{display:flex;flex-direction:column;gap:5px;min-width:220px;font-size:12.5px;color:#3c434a}.clusterlane-review-filters{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}.clusterlane-review-filters button{border:1px solid #dcdfe6;background:#fff;border-radius:999px;padding:5px 12px;font-size:12.5px;color:#3c434a;cursor:pointer}.clusterlane-review-filters button em{font-style:normal;color:#8c8f94;margin-left:2px}.clusterlane-review-filters button.is-active{background:#16223a;border-color:#16223a;color:#fff}.clusterlane-review-filters button.is-active em{color:#cbd5e1}.clusterlane-review-list{border:1px solid #e3e6ee;border-radius:10px;overflow:hidden}.clusterlane-review-row{display:grid;grid-template-columns:minmax(170px,1.1fr) minmax(0,3fr) auto;gap:14px;align-items:center;padding:11px 14px;border-bottom:1px solid #eef0f4;background:#fff}.clusterlane-review-row:last-child{border-bottom:0}.clusterlane-review-name{display:flex;flex-direction:column;gap:2px;min-width:0}.clusterlane-review-name strong{font-size:13.5px}.clusterlane-review-name span{font-size:12px;color:#646970}.clusterlane-review-result{display:flex;flex-direction:column;gap:5px;min-width:0;font-size:12.5px}.clusterlane-review-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.clusterlane-review-tag{display:inline-flex;align-items:center;gap:4px;flex:none;padding:2px 8px;border-radius:999px;font-size:11.5px;font-weight:600}.clusterlane-review-tag svg{width:12px;height:12px}.clusterlane-review-tag--split{background:#ede9fe;color:#5b21b6}.clusterlane-review-tag--idea{background:#fef3c7;color:#92400e}.clusterlane-review-tag--pillar{background:#fff7ed;color:#9a3412}.clusterlane-review-tag--ok{background:#dcfce7;color:#166534}.clusterlane-review-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;background:#f1f5f9;color:#334155;font-size:12px}.clusterlane-review-chip em{font-style:normal;color:#8c8f94}.clusterlane-review-chip svg{width:12px;height:12px;color:#b45309}.clusterlane-review-text{color:#3c434a}.clusterlane-review-muted{color:#8c8f94;font-size:12px;display:inline-flex;gap:4px;align-items:center}.clusterlane-review-muted svg{width:13px;height:13px}.clusterlane-review-stale{font-style:italic}.clusterlane-review-actions{display:flex;gap:6px;justify-content:flex-end}.clusterlane-review-actions a.clusterlane-tm-btn{text-decoration:none}.clusterlane-review-small{margin:10px 2px 0;font-size:12px;color:#8c8f94}@media (max-width:760px){.clusterlane-review-row{grid-template-columns:minmax(0,1fr);gap:8px}.clusterlane-review-actions{justify-content:flex-start}.clusterlane-review-body,.clusterlane-review-head{padding-left:14px;padding-right:14px}}@media (max-width:782px){.clusterlane-set-tabs{display:grid;grid-template-columns:1fr 1fr;overflow:visible}.clusterlane-set-tabs button{justify-content:center;min-width:0}.clusterlane-ca-table,.clusterlane-ca-table>tbody,.clusterlane-ca-table>thead{display:block}.clusterlane-ca-table>tbody>tr.clusterlane-ca-row,.clusterlane-ca-table>thead>tr{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center}.clusterlane-ca-table>thead>tr>th:nth-child(5){display:none}.clusterlane-ca-table>thead>tr>th:not(:first-child){width:auto}.clusterlane-ca-table>tbody>tr.clusterlane-ca-row>td{border-bottom:0}.clusterlane-ca-table>tbody>tr.clusterlane-ca-row>td.clusterlane-ca-actions{grid-column:1/-1;display:flex;gap:6px;padding-top:0;border-bottom:1px solid #eef0f4}.clusterlane-ca-table td.clusterlane-ca-actions .clusterlane-tm-btn{display:inline-flex;margin:0}.clusterlane-ca-table>tbody>tr.clusterlane-ca-editor-row,.clusterlane-ca-table>tbody>tr.clusterlane-ca-editor-row>td{display:block}#clusterlane-dash-graph-card>.clusterlane-site-analysis-header,#clusterlane-site-analysis-card>.clusterlane-site-analysis-header,#clusterlane-sm-card>.clusterlane-site-analysis-header{padding:18px 18px;gap:12px}#clusterlane-dash-graph-card .clusterlane-site-analysis-title,#clusterlane-site-analysis-card .clusterlane-site-analysis-title,#clusterlane-sm-card .clusterlane-site-analysis-title{flex:1 1 calc(100% - 40px);min-width:0;order:0}#clusterlane-dash-graph-card>.clusterlane-site-analysis-header::after,#clusterlane-site-analysis-card>.clusterlane-site-analysis-header::after,#clusterlane-sm-card>.clusterlane-site-analysis-header::after{order:1}#clusterlane-dash-graph-card .clusterlane-dash-graph-controls,#clusterlane-site-analysis-card .clusterlane-site-analysis-actions,#clusterlane-sm-card .clusterlane-dash-graph-controls{order:2;flex:1 1 100%;min-width:0;flex-wrap:wrap}#clusterlane-dash-graph-select{flex:1 1 140px;min-width:0;max-width:100%}.clusterlane-dash-graph-ci-btn{flex:1 1 100%;justify-content:center;text-align:center;box-sizing:border-box;min-height:36px;padding:8px 16px}.clusterlane-site-analysis-meta{white-space:nowrap}}@media (max-width:782px){#clusterlane-ci-list-card>.clusterlane-collapse-body{padding:12px 8px 14px}.clusterlane-ci-table>tbody>tr.clusterlane-ci-detail-row>td{padding:0 0 8px 3px}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel>.clusterlane-panel-card{border-radius:10px}.clusterlane-ci-detail-row #clusterlane-ci-detail-panel>.clusterlane-panel-card>div[style*="padding:20px 24px"]{padding:10px 6px!important}.clusterlane-ci-detail-row .clusterlane-improve-panel{border-radius:10px;margin-bottom:18px}.clusterlane-ci-detail-row .clusterlane-improve-panel-header{padding:14px 12px}.clusterlane-ci-detail-row .clusterlane-improve-panel-body{padding:8px 6px 10px;gap:8px}.clusterlane-ci-detail-row .clusterlane-improve-block-body{padding:10px 8px 12px}.clusterlane-ci-detail-row .clusterlane-tm-group{border-radius:8px}.clusterlane-ci-detail-row .clusterlane-tm-row{padding:10px;gap:10px}.clusterlane-ci-detail-row .clusterlane-ls2-row-head{flex-direction:column;align-items:stretch;gap:8px;padding:10px}.clusterlane-ci-detail-row .clusterlane-ls2-row-main{min-width:0}.clusterlane-ci-detail-row .clusterlane-ls2-row-actions{align-self:flex-start}.clusterlane-ci-detail-row .clusterlane-ci-al-src{max-width:100%}.clusterlane-ci-detail-row .clusterlane-improve-block-header{padding:10px;gap:8px;align-items:flex-start}.clusterlane-ci-detail-row .clusterlane-improve-block-label{gap:8px;min-width:0;align-items:flex-start}.clusterlane-ci-detail-row .clusterlane-improve-block-meta{gap:6px;flex:none;padding-top:4px}.clusterlane-ci-detail-row .clusterlane-improve-count{white-space:nowrap;padding:3px 8px}.clusterlane-ci-detail-row .clusterlane-improve-summary{padding:10px 12px;margin-bottom:10px}}#clusterlane-ci-improve .clusterlane-improve-panel-body{counter-reset:clusterlane-step}#clusterlane-ci-improve .clusterlane-improve-block{counter-increment:clusterlane-step}#clusterlane-ci-improve .clusterlane-improve-block-icon{width:28px;height:28px;border-radius:50%;background:#16223a;color:#fff;font-size:13.5px;font-weight:700;font-variant-numeric:tabular-nums;box-shadow:0 1px 2px rgba(15,23,42,.25)}#clusterlane-ci-improve .clusterlane-improve-block-icon>*{display:none}#clusterlane-ci-improve .clusterlane-improve-block-icon::before{content:counter(clusterlane-step);line-height:1}#clusterlane-ci-improve .clusterlane-improve-block-header:hover .clusterlane-improve-block-icon{background:#1e3a5f}.clusterlane-ci-graph-modal-header .clusterlane-graph-legend{margin-top:0;padding-top:0;border-top:0}@media (max-width:782px){.clusterlane-ci-graph-modal-overlay{padding:0;align-items:stretch}.clusterlane-ci-graph-modal-inner{height:100%;height:100dvh;max-height:none;border-radius:0;box-shadow:none}.clusterlane-ci-graph-modal-header{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-areas:"title close" "info info" "legend legend";align-items:center;gap:8px 12px;padding:12px 14px}.clusterlane-ci-graph-modal-header>div{display:contents!important}.clusterlane-ci-graph-modal-title{grid-area:title;font-size:1.05rem;line-height:1.3;overflow-wrap:anywhere}#clusterlane-ci-graph-modal-close{grid-area:close}#clusterlane-ci-graph-modal-infobar{grid-area:info}#clusterlane-ci-graph-modal-legend{grid-area:legend}.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar{gap:4px 12px;font-size:11.5px}.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar>span{padding:0!important;white-space:nowrap}.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar>span::after{display:none}.clusterlane-ci-graph-modal-header .clusterlane-graph-infobar .clusterlane-graph-infobar-arch{padding:1px 9px!important;margin:0}.clusterlane-ci-graph-modal-body{flex-direction:column}.clusterlane-ci-graph-modal-canvas{flex:1 1 auto}.clusterlane-ci-graph-modal-right{width:auto;max-height:42%;flex:0 0 auto;padding:12px 14px calc(12px + env(safe-area-inset-bottom));border-left:0;border-top:1px solid #e2e8f0}.clusterlane-ci-graph-modal-right>p{margin:0!important}}@media screen and (max-width:600px){#clusterlane-sticky-bar,.clusterlane-stickybar{top:0}}.clusterlane-ai-improve__list{counter-reset:clusterlane-imp}.clusterlane-ai-improve__list>.clusterlane-ai-improve__item{counter-increment:clusterlane-imp}.clusterlane-ai-improve__item-head{flex-wrap:nowrap;align-items:flex-start;gap:10px}.clusterlane-ai-improve__item-head strong{flex:0 1 auto;min-width:0;line-height:1.4;padding-top:3px}.clusterlane-ai-improve__first{flex:none;margin-top:4px}.clusterlane-ai-improve__node{width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;background:#16223a;color:#fff;box-shadow:0 1px 2px rgba(15,23,42,.25);font-size:13px;font-weight:700;font-variant-numeric:tabular-nums}.clusterlane-ai-improve__node::before{content:counter(clusterlane-imp);line-height:1}.clusterlane-ai-improve__actions,.clusterlane-ai-improve__item .clusterlane-tm-group,.clusterlane-ai-improve__lead,.clusterlane-ai-improve__more,.clusterlane-ai-improve__why{margin-left:36px}.clusterlane-ai-improve__tips{margin-left:54px}@media (max-width:782px){.clusterlane-ai-improve__actions,.clusterlane-ai-improve__item .clusterlane-tm-group,.clusterlane-ai-improve__lead,.clusterlane-ai-improve__more,.clusterlane-ai-improve__why{margin-left:0}.clusterlane-ai-improve__tips{margin-left:18px}.clusterlane-improve-cta{flex-direction:column;align-items:stretch;gap:10px}.clusterlane-improve-cta>a,.clusterlane-improve-cta>button{align-self:flex-start}}.clusterlane-browse__seo-link{margin-left:auto;display:inline-flex;align-items:center;gap:5px;font-size:12.5px;font-weight:600;color:#1d4ed8;text-decoration:none}.clusterlane-browse__seo-link .clusterlane-g{width:14px;height:14px}.clusterlane-browse__seo-link:hover{text-decoration:underline}.clusterlane-seot.is-loading .clusterlane-seot__list{opacity:.55;transition:opacity .15s}.clusterlane-seot__note{color:#92400e!important;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:8px 12px;margin:0 0 12px!important}.clusterlane-seot__dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:1px}.clusterlane-seot__dot--bad{background:#ef4444}.clusterlane-seot__dot--warn{background:#f59e0b}.clusterlane-seot__bulk{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;margin:14px 0 12px;padding:10px 14px;border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc}.clusterlane-seot__all{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:#334155;cursor:pointer}.clusterlane-seot__bulk-actions{margin-left:auto;display:flex;flex-wrap:wrap;gap:8px}.clusterlane-seot__bulk-actions .clusterlane-tm-btn{display:inline-flex;align-items:center;gap:6px}.clusterlane-seot__bulk-actions [hidden]{display:none!important}.clusterlane-seot__run[hidden]{display:none!important}.clusterlane-seot__bulk-actions .clusterlane-g{width:14px;height:14px}.clusterlane-seot__bulk-actions .clusterlane-pro-badge{margin-left:2px}.clusterlane-seot__run{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding-top:10px;border-top:1px solid #e2e8f0;font-size:13px;color:#334155}.clusterlane-seot__run .clusterlane-g{width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:#7c3aed}.clusterlane-seot__example{margin-bottom:18px}.clusterlane-seot__list{display:grid;gap:12px}.clusterlane-seot__row{border:1px solid #e2e8f0;border-left:3px solid #e2e8f0;border-radius:12px;background:#fff;padding:12px 14px}.clusterlane-seot__row.is-selected{background:#f8fbff;border-color:#bfdbfe}.clusterlane-seot__row.is-changed{border-left-color:#2563eb}.clusterlane-seot__row.is-applied{border-left-color:#16a34a}.clusterlane-seot--compact #clusterlane-seot-rows{gap:0}.clusterlane-seot__row--compact{display:grid;grid-template-columns:18px 32px minmax(180px,1.15fr) minmax(0,1fr) minmax(0,1.6fr) auto;align-items:center;gap:10px 14px;padding:9px 14px;border-radius:0;border-width:0 0 1px;border-left:0}.clusterlane-seot--compact #clusterlane-seot-rows>.clusterlane-seot__row:first-child{border-top-left-radius:12px;border-top-right-radius:12px}.clusterlane-seot--compact #clusterlane-seot-rows>.clusterlane-seot__row--compact{border:1px solid #e2e8f0;border-top-width:0}.clusterlane-seot--compact #clusterlane-seot-rows>.clusterlane-seot__row--compact:first-child{border-top-width:1px}.clusterlane-seot--compact #clusterlane-seot-rows>.clusterlane-seot__row:last-child{border-bottom-left-radius:12px;border-bottom-right-radius:12px}.clusterlane-seot--compact #clusterlane-seot-rows>.clusterlane-seot__row:not(.clusterlane-seot__row--compact){margin:8px 0}.clusterlane-seot__row--compact:hover{background:#f8fafc}.clusterlane-seot__row--compact .clusterlane-seot__name{min-width:0;display:flex;flex-direction:column;gap:3px}.clusterlane-seot__row--compact .clusterlane-seot__name a{font-weight:600;color:#0f172a;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clusterlane-seot__row--compact .clusterlane-seot__name a:hover{color:#1d4ed8;text-decoration:underline}.clusterlane-seot__cchips{display:flex;flex-wrap:wrap;gap:4px}.clusterlane-seot__cchips .clusterlane-serp__chip{font-size:10.5px;padding:1px 6px}.clusterlane-seot__cval{min-width:0;display:flex;flex-direction:column;gap:1px}.clusterlane-seot__clbl{font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#94a3b8}.clusterlane-seot__ctext{font-size:12.5px;color:#334155;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.clusterlane-seot__cval:not(.clusterlane-seot__cval--desc) .clusterlane-seot__ctext{-webkit-line-clamp:1}.clusterlane-seot__cnone,.clusterlane-seot__cnone em{color:#94a3b8;font-style:italic}.clusterlane-seot__edit{gap:5px}.clusterlane-seot__edit .clusterlane-g{width:13px;height:13px;margin:0}.clusterlane-seot__collapse{margin-left:auto}.clusterlane-seot__row--compact.is-changed{box-shadow:inset 3px 0 0 #2563eb}.clusterlane-seot__cstate{background:#eff6ff;color:#1d4ed8}.clusterlane-seot__cchips .clusterlane-g{width:11px;height:11px;margin:0 3px 0 0;vertical-align:-1px}.clusterlane-seot__ai-one{display:inline-flex;flex-wrap:wrap;gap:6px;margin-right:6px}.clusterlane-seot__ai-one .clusterlane-tm-btn{gap:5px;height:28px;padding:0 10px}.clusterlane-seot__ai-one .clusterlane-g{width:13px;height:13px;margin:0;color:#7c3aed}.clusterlane-seot__ai-one .clusterlane-pro-badge{margin-left:2px}.clusterlane-seot__view{margin-left:auto}.clusterlane-seot__view+.clusterlane-csv-btn{margin-left:0}@media (max-width:960px){.clusterlane-seot__row--compact{grid-template-columns:18px 32px minmax(0,1fr) auto}.clusterlane-seot__row--compact .clusterlane-seot__cval{grid-column:3/-1}.clusterlane-seot__row--compact .clusterlane-seot__edit{grid-column:4;grid-row:1}}.clusterlane-seot__head{display:flex;align-items:center;gap:10px}.clusterlane-seot__sel{margin:0!important;flex:none}.clusterlane-seot__name{min-width:0;flex:1 1 auto;display:flex;flex-direction:column}.clusterlane-seot__name a,.clusterlane-seot__name-text{font-weight:600;font-size:14px;color:#0f172a;text-decoration:none;overflow-wrap:anywhere}.clusterlane-seot__name a:hover{color:#1d4ed8;text-decoration:underline}.clusterlane-seot__chips-row{display:flex;flex-wrap:wrap;gap:4px;justify-content:flex-end}.clusterlane-seot .clusterlane-serp__chip--ok{background:#dcfce7;color:#166534}.clusterlane-seot .clusterlane-serp__chip--warn{background:#fef3c7;color:#92400e}.clusterlane-seot .clusterlane-serp__chip--bad{background:#fee2e2;color:#991b1b}.clusterlane-seot__fields{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:12px;margin-top:10px}.clusterlane-seot__field{min-width:0}.clusterlane-seot .clusterlane-seo-ai__lbl{color:#64748b}.clusterlane-seot .clusterlane-seo-ai__count--ok{background:#dcfce7;color:#166534}.clusterlane-seot .clusterlane-seo-ai__count--warn{background:#fef3c7;color:#92400e}.clusterlane-seot .clusterlane-seo-ai__count--none{background:#f1f5f9;color:#64748b}.clusterlane-seot__in{width:100%;box-sizing:border-box;border:1px solid #cbd5e1!important;border-radius:8px!important;padding:6px 10px!important;font-size:13.5px;line-height:1.45;color:#0f172a;background:#fff}textarea.clusterlane-seot__in{resize:vertical;min-height:58px}.clusterlane-seot__in:focus{border-color:#3b82f6!important;box-shadow:0 0 0 2px rgba(59,130,246,.2)!important;outline:0}.clusterlane-seot__in::placeholder{color:#94a3b8}.clusterlane-seot__from{margin:4px 0 0;font-size:12px;color:#94a3b8;font-style:italic}.clusterlane-seot__ai{display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px 8px;margin-top:6px;padding:7px 10px;border-radius:8px;background:#f5f3ff;border:1px dashed #c4b5fd;font-size:12.5px;line-height:1.45;color:#312e81}.clusterlane-seot__ai.is-used{background:#eef2ff;border-style:solid;border-color:#a5b4fc}.clusterlane-seot__ai-tag{display:inline-flex;align-items:center;gap:3px;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#6d28d9;padding-top:1px}.clusterlane-seot__ai-tag .clusterlane-g{width:12px;height:12px}.clusterlane-seot__ai-text{flex:1 1 200px;min-width:0}.clusterlane-seot__ai-text--used{color:#4338ca;font-style:italic}.clusterlane-seot__ai-acts{display:inline-flex;align-items:center;gap:8px;margin-left:auto;white-space:nowrap}.clusterlane-seot__ai-nav{display:inline-flex;align-items:center;gap:2px;color:#64748b;font-variant-numeric:tabular-nums}.clusterlane-seot__ai-nav button{border:0;background:0 0;cursor:pointer;color:#4c1d95;font-size:15px;line-height:1;padding:0 4px}.clusterlane-seot__use{border:1px solid #7c3aed;background:#7c3aed;color:#fff;border-radius:6px;padding:2px 10px;font-size:12px;font-weight:600;cursor:pointer}.clusterlane-seot__use:hover{background:#6d28d9}.clusterlane-seot__ai-on{display:inline-flex;align-items:center;gap:3px;font-weight:600;color:#3730a3}.clusterlane-seot__ai-on .clusterlane-g{width:13px;height:13px}.clusterlane-seot__foot{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:10px}.clusterlane-seot__link{display:inline-flex;align-items:center;gap:4px;border:0;background:0 0;padding:0;cursor:pointer;color:#1d4ed8;font-size:12.5px;font-weight:600;text-decoration:none}.clusterlane-seot__link .clusterlane-g{width:13px;height:13px}.clusterlane-seot__link:hover{text-decoration:underline}.clusterlane-seot__foot-state{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:12.5px}.clusterlane-seot__state{color:#64748b;display:inline-flex;align-items:center;gap:4px}.clusterlane-seot__state .clusterlane-g{width:13px;height:13px}.clusterlane-seot__state--ok,.clusterlane-seot__state--ok .clusterlane-g{color:#15803d}.clusterlane-seot__state--bad{color:#b91c1c}.clusterlane-seot__state--changed{color:#1d4ed8;font-weight:600}.clusterlane-seot__foot-btns{margin-left:auto;display:flex;gap:8px}.clusterlane-seot .clusterlane-seot__serp{max-width:600px;background:#fff;border-color:#e2e8f0;-webkit-backdrop-filter:none;backdrop-filter:none}.clusterlane-seot .clusterlane-serp__fav{background:#f1f3f4;border-color:#e2e8f0;color:#202124}.clusterlane-seot .clusterlane-serp__name{color:#202124}.clusterlane-seot .clusterlane-serp__url{color:#4d5156}.clusterlane-seot .clusterlane-serp__title{color:#1a0dab}.clusterlane-seot .clusterlane-serp__desc{color:#4d5156}.clusterlane-seot .clusterlane-serp__desc--none{color:#70757a}@media (max-width:782px){.clusterlane-browse__seo-link{margin-left:0}.clusterlane-seot__bulk{padding:10px}.clusterlane-seot__bulk-actions{margin-left:0;width:100%}.clusterlane-seot__bulk-actions .clusterlane-tm-btn{flex:1 1 auto;justify-content:center}.clusterlane-seot__row{padding:10px}.clusterlane-seot__head{flex-wrap:wrap}.clusterlane-seot__name{flex-basis:calc(100% - 90px)}.clusterlane-seot__chips-row{flex-basis:100%;justify-content:flex-start}.clusterlane-seot__fields{grid-template-columns:minmax(0,1fr);gap:10px}.clusterlane-seot__ai-acts{margin-left:0}.clusterlane-seot__foot-btns{margin-left:0;width:100%}.clusterlane-seot__foot-btns .clusterlane-tm-btn{flex:1 1 auto}}.clusterlane-seot__dot--ai{background:#7c3aed}.clusterlane-seot__aichip.is-stale{opacity:.55}.clusterlane-seot__review{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;margin:10px 0 0;padding:7px 10px;border-radius:8px;background:#faf5ff;border:1px solid #e9d5ff;font-size:12.5px;line-height:1.45;color:#334155}.clusterlane-seot__review.is-stale{background:#f8fafc;border-color:#e2e8f0;color:#64748b}.clusterlane-seot__review .clusterlane-seot__ai-tag{align-self:center}.clusterlane-seot__rv-part b{font-weight:700}.clusterlane-seot__rv-part--bad b{color:#b91c1c}.clusterlane-seot__rv-part--warn b{color:#b45309}.clusterlane-seot__rv-part--ok b{color:#15803d}.clusterlane-seot__rv-stale{font-style:italic;color:#94a3b8}@media (max-width:782px){.clusterlane-seot__review{flex-direction:column;gap:3px}.clusterlane-seot__review .clusterlane-seot__ai-tag{align-self:flex-start}}.clusterlane-ls-placement-note{display:flex;align-items:center;gap:6px;margin:0 0 8px;padding:7px 10px;border-radius:8px;background:#fffbeb;border:1px solid #fde68a;color:#92400e;font-size:12.5px}.clusterlane-ls-placement-note[hidden]{display:none}.clusterlane-ls-placement-note .clusterlane-g{width:14px;height:14px;flex:none}.clusterlane-split-by{display:flex;flex-wrap:wrap;align-items:center;gap:6px 8px;margin:4px 0 12px}.clusterlane-split-by-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#64748b}.clusterlane-split-by-btn{border:1px solid #cbd5e1;background:#fff;color:#334155;border-radius:999px;padding:4px 12px;font-size:12.5px;font-weight:600;cursor:pointer}.clusterlane-split-by-btn em{font-style:normal;color:#94a3b8;font-weight:600;margin-left:2px}.clusterlane-split-by-btn.is-active{background:#16223a;border-color:#16223a;color:#fff}.clusterlane-split-by-btn.is-active em{color:#cbd5e1}.clusterlane-split-by-hint{font-size:12px;color:#94a3b8}.clusterlane-ci-group{display:flex;align-items:center;gap:8px}.clusterlane-ci-group[hidden]{display:none!important}.clusterlane-ci-table tr.clusterlane-ci-section-row td{padding:0;background:#f1f5f9;border-top:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0}.clusterlane-ci-section-toggle{display:flex;align-items:center;gap:10px;width:100%;padding:10px 14px;border:0;background:0 0;cursor:pointer;font:inherit;text-align:left;color:#16223a}.clusterlane-ci-section-toggle:hover{background:#e8edf3}.clusterlane-ci-section-toggle:focus-visible{outline:2px solid #2563eb;outline-offset:-2px}.clusterlane-ci-section-caret{width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:6px solid #64748b;transition:transform .15s}.clusterlane-ci-section-row.is-folded .clusterlane-ci-section-caret{transform:rotate(-90deg)}.clusterlane-ci-section-name{font-weight:700;font-size:13px;letter-spacing:.02em;text-transform:uppercase}.clusterlane-ci-section-meta{font-size:12px;color:#64748b;flex:1}.clusterlane-ci-section-toggle .clusterlane-ci-health-cell{margin-left:auto;font-weight:700;font-size:13px}.clusterlane-ci-section-pick{display:inline-flex;align-items:center;gap:6px}.clusterlane-ci-section-pick select{font-size:12px;padding:1px 22px 1px 6px;min-height:26px;border-radius:6px}@media (max-width:600px){.clusterlane-ci-section-meta{display:none}}.clusterlane-graph-infobar .clusterlane-ci-section-pick select{height:24px;min-height:24px;line-height:1;padding:0 22px 0 8px;background-color:rgba(255,255,255,.08);color:#fff;border:1px solid rgba(255,255,255,.25);background-position:right 4px center;background-size:14px}.clusterlane-graph-infobar .clusterlane-ci-section-pick select option{color:#16223a;background:#fff}.clusterlane-ci-group .clusterlane-seg[hidden]{display:none!important}.clusterlane-ci-new-group{padding:5px 10px;font-size:12px}.clusterlane-ci-section-bar{display:flex;align-items:center}.clusterlane-ci-section-bar .clusterlane-ci-section-toggle{flex:1;min-width:0}.clusterlane-ci-section-row.is-mine td{background:#eef2ff}.clusterlane-ci-section-row.is-mine .clusterlane-ci-section-name{color:#3730a3}.clusterlane-ci-section-acts{display:flex;gap:2px;padding-right:10px}.clusterlane-ci-section-acts button{width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:6px;background:0 0;color:#475569;cursor:pointer;font-size:14px;line-height:1}.clusterlane-ci-section-acts button:hover:not(:disabled){background:#dbe3f5;color:#16223a}.clusterlane-ci-section-acts button:disabled{opacity:.3;cursor:default}.clusterlane-ci-section-acts button svg{width:13px;height:13px}.clusterlane-ci-table tr.clusterlane-ci-cluster-row[draggable=true]{cursor:grab}.clusterlane-ci-table tr.clusterlane-ci-cluster-row.is-dragging{opacity:.45}#clusterlane-ci-cluster-list.is-dragging tr.clusterlane-ci-section-row td{outline:1px dashed #94a3b8;outline-offset:-3px}#clusterlane-ci-cluster-list tr.clusterlane-ci-section-row.is-drop td{background:#dbeafe;outline:2px solid #2563eb;outline-offset:-3px}.clusterlane-ci-section-bar{cursor:pointer}.clusterlane-ci-section-bar:hover{background:#e8edf3}.clusterlane-ci-section-row.is-mine .clusterlane-ci-section-bar:hover{background:#e0e7ff}.clusterlane-ci-section-bar .clusterlane-ci-section-toggle{flex:0 1 auto;width:auto}.clusterlane-ci-section-bar .clusterlane-ci-section-toggle:hover{background:0 0}.clusterlane-ci-section-meta{flex:none}.clusterlane-ci-section-acts{padding-right:0}.clusterlane-ci-section-health{margin-left:auto;padding:0 14px 0 12px;font-weight:700;font-size:13px;white-space:nowrap}.clusterlane-ci-section-health{padding-right:12px}@media (max-width:600px){.clusterlane-ci-section-health{padding-right:8px}}.clusterlane-toolbar--rows{flex-direction:column;align-items:stretch;gap:8px}.clusterlane-toolbar-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.clusterlane-toolbar--rows .clusterlane-toolbar-row+.clusterlane-toolbar-row{padding-top:8px;border-top:1px solid #e2e8f0}.clusterlane-toolbar--rows.is-map .clusterlane-ci-group{display:none!important}.clusterlane-csv-btn{display:inline-flex;align-items:center;gap:6px;margin-left:auto;padding:5px 10px;border:1px solid #e2e8f0;border-radius:7px;background:#fff;color:#334155;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}.clusterlane-csv-btn:hover{border-color:#94a3b8;color:#16223a}.clusterlane-csv-btn:disabled{opacity:.5;cursor:wait}.clusterlane-csv-btn svg{width:14px;height:14px}.clusterlane-browse__head .clusterlane-browse__seo-link+.clusterlane-csv-btn{margin-left:8px}.clusterlane-lang-tag{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid #cbd5e1;border-radius:4px;font-size:10px;font-weight:700;line-height:16px;letter-spacing:.04em;text-transform:uppercase;color:#475569;background:#f8fafc;vertical-align:middle}#clusterlane-ai-browse select#clusterlane-ab-lang,#clusterlane-seot select#clusterlane-seot-lang{max-width:160px}.clusterlane-graph-trunc{margin:0 0 8px;padding:7px 10px;border-radius:7px;background:#fef9c3;color:#713f12;font-size:12px;line-height:1.45}.clusterlane-type-tag{border-color:#c4b5fd;color:#5b21b6;background:#f5f3ff;text-transform:none;letter-spacing:0}.clusterlane-type-filter__txt{margin-left:4px;font-size:12px}.clusterlane-low-links{display:flex;gap:10px;align-items:flex-start;margin:12px 0 16px;padding:12px 16px;border:1px solid #fcd34d;border-left-width:4px;background:#fffbeb;border-radius:8px;color:#78350f;font-size:13px;line-height:1.55}.clusterlane-low-links p{margin:0}.clusterlane-low-links a{font-weight:600;white-space:nowrap}.clusterlane-stickybar__pages{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 11px;border-radius:8px;border:1px solid rgba(255,255,255,.12);color:var(--sb-text);font-size:12px;white-space:nowrap;cursor:default;background:linear-gradient(90deg,rgba(59,130,246,.35) var(--clusterlane-pages-pct,0),transparent var(--clusterlane-pages-pct,0))}.clusterlane-stickybar__pages[hidden]{display:none}.clusterlane-stickybar__pages svg{width:13px;height:13px;animation:clusterlane-pages-spin 1.6s linear infinite}@keyframes clusterlane-pages-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.clusterlane-stickybar__pages svg{animation:none}}.clusterlane-ca-hub .clusterlane-ca-term-name{display:flex;flex-direction:column;gap:2px}.clusterlane-ca-hub-paths{font-weight:400;font-size:12px;color:#64748b}.clusterlane-ca-status--on{color:#047857;font-weight:600;font-size:12px}.clusterlane-ca-hub-list{margin:0 0 6px 28px;border-left:2px solid #e2e8f0;padding-left:10px}.clusterlane-ca-hub-item{cursor:default}.clusterlane-tm-bulk{display:inline-flex;align-items:center;gap:8px;margin-left:auto}.clusterlane-tm-bulk-progress{font-size:12.5px;color:#475569}.clusterlane-tm-row--failed{border-left:3px solid #f59e0b}.clusterlane-tm-error{margin:4px 0 0;font-size:12px;color:#b45309;display:flex;gap:5px;align-items:center}.clusterlane-tm-error svg{width:13px;height:13px}.clusterlane-gib-tech{display:inline-flex;align-items:center;gap:14px}.clusterlane-gib-tech>summary{cursor:pointer;list-style:none;font-size:12px;color:inherit;opacity:.75;text-decoration:underline dotted;text-underline-offset:3px}.clusterlane-gib-tech>summary::-webkit-details-marker{display:none}.clusterlane-gib-tech>summary::after{content:" ▸"}.clusterlane-gib-tech[open]>summary::after{content:" ▾"}.clusterlane-gib-tech>summary:focus-visible{outline:2px solid #60a5fa;outline-offset:2px;border-radius:4px}.clusterlane-ci-detail-back{margin:16px 0 0;text-align:center}.clusterlane-ci-detail-back .clusterlane-g{transform:rotate(180deg);margin-right:6px}.clusterlane-ls2-regen .clusterlane-tm-linkbtn .clusterlane-g{width:13px;height:13px;margin:0 4px 0 0;vertical-align:-2px;color:#7c3aed}.clusterlane-rp-table th.clusterlane-rp-col-quick{width:44px}.clusterlane-rp-table td.clusterlane-rp-col-quick{padding-left:0;padding-right:10px;text-align:right}.clusterlane-ci-quick,.clusterlane-rp-quick{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:1px solid transparent;border-radius:8px;background:0 0;color:#64748b;cursor:pointer}.clusterlane-ci-quick:focus-visible,.clusterlane-ci-quick:hover,.clusterlane-rp-quick:focus-visible,.clusterlane-rp-quick:hover{color:#2563eb;background:#eff6ff;border-color:#bfdbfe}.clusterlane-ci-quick:focus-visible,.clusterlane-rp-quick:focus-visible{outline:2px solid #2563eb;outline-offset:1px}.clusterlane-ci-quick .clusterlane-g,.clusterlane-rp-quick .clusterlane-g{width:17px;height:17px;margin:0}.clusterlane-rp-table tbody tr:hover .clusterlane-rp-quick{color:#2563eb}.clusterlane-ci-table .clusterlane-browse__open{max-width:calc(100% - 64px)}.clusterlane-ci-quick{width:26px;height:26px;margin:-4px 0 -4px 6px;vertical-align:top;opacity:0}.clusterlane-ci-cluster-row--active .clusterlane-ci-quick,.clusterlane-ci-cluster-row:hover .clusterlane-ci-quick,.clusterlane-ci-quick:focus-visible{opacity:1}@media (hover:none){.clusterlane-ci-quick{opacity:1}}@media print{.clusterlane-rp-col-quick{display:none!important}}html.clusterlane-cq-lock,html.clusterlane-cq-lock body{overflow:hidden}.clusterlane-cq-overlay{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;background:rgba(15,23,42,.62)}.clusterlane-cq-overlay[hidden]{display:none}.clusterlane-cq{width:100%;max-width:860px;height:min(88vh,900px);display:flex;flex-direction:column;overflow:hidden;background:#f8fafc;border-radius:14px;box-shadow:0 24px 64px rgba(0,0,0,.32);outline:0;font-size:13px;color:#1e293b}.clusterlane-cq__head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:16px 18px 16px 22px;background:linear-gradient(135deg,#0f172a 0,#1e3a5f 100%);color:#fff;flex-shrink:0}.clusterlane-cq__headtext{min-width:0}.clusterlane-cq__kicker{display:flex;align-items:center;flex-wrap:wrap;gap:6px;font-size:12px;color:rgba(255,255,255,.72);min-height:18px}.clusterlane-cq__kicker .clusterlane-g{width:14px;height:14px;margin:0}.clusterlane-cq__kicker .clusterlane-tip-icon{background:rgba(255,255,255,.18);color:#fff}.clusterlane-cq__dot{opacity:.5}.clusterlane-cq__title{margin:4px 0 0;font-size:20px;line-height:1.25;font-weight:700;color:#fff;overflow-wrap:anywhere}.clusterlane-cq__tools{display:flex;align-items:center;gap:6px;flex-shrink:0}.clusterlane-cq__pos{font-size:12px;color:rgba(255,255,255,.6);margin-right:4px;font-variant-numeric:tabular-nums}.clusterlane-cq__close,.clusterlane-cq__navbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:1px solid rgba(255,255,255,.18);border-radius:8px;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;cursor:pointer}.clusterlane-cq__navbtn[hidden]{display:none}.clusterlane-cq__close:focus-visible,.clusterlane-cq__close:hover,.clusterlane-cq__navbtn:focus-visible,.clusterlane-cq__navbtn:hover{background:rgba(255,255,255,.2);outline:0}.clusterlane-cq__close{margin-left:6px;font-size:22px}.clusterlane-cq__body{flex:1;min-height:0;overflow-y:auto;padding:18px 22px 20px}.clusterlane-cq__body.is-loading>*{opacity:.45;transition:opacity .15s;pointer-events:none}.clusterlane-cq__body.is-loading{cursor:progress}.clusterlane-cq__foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 22px;border-top:1px solid #e2e8f0;background:#fff;flex-shrink:0}.clusterlane-cq__keys{font-size:11.5px;color:#94a3b8}.clusterlane-cq__keys kbd{display:inline-block;min-width:18px;padding:1px 5px;margin-right:3px;border:1px solid #e2e8f0;border-bottom-width:2px;border-radius:4px;background:#f8fafc;font:inherit;font-size:11px;text-align:center;color:#64748b}.clusterlane-cq__open{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;text-decoration:none}.clusterlane-cq__open:focus,.clusterlane-cq__open:hover{background:#1d4ed8;color:#fff}.clusterlane-cq__open .clusterlane-g{width:15px;height:15px;margin:0}.clusterlane-cq__error,.clusterlane-cq__loading{display:flex;align-items:center;justify-content:center;gap:10px;min-height:260px;margin:0;color:#64748b}.clusterlane-cq__error{color:#b91c1c}.clusterlane-cq__spin{width:18px;height:18px;border:2px solid #cbd5e1;border-top-color:#2563eb;border-radius:50%;animation:clusterlane-cq-spin .8s linear infinite}@keyframes clusterlane-cq-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.clusterlane-cq__spin{animation-duration:2.4s}}.clusterlane-cq__label{display:flex;align-items:center;gap:4px;font-size:11.5px;font-weight:600;color:#64748b}.clusterlane-cq__top{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:10px}.clusterlane-cq__health,.clusterlane-cq__tile{background:#fff;border:1px solid #e2e8f0;border-radius:10px}.clusterlane-cq__health{padding:14px 16px 10px}.clusterlane-cq__hrow{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px;margin:2px 0 6px}.clusterlane-cq__hval{font-size:34px;line-height:1.1;font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums}.clusterlane-cq__health--good .clusterlane-cq__hval{color:#15803d}.clusterlane-cq__health--fair .clusterlane-cq__hval{color:#b45309}.clusterlane-cq__health--weak .clusterlane-cq__hval{color:#dc2626}.clusterlane-cq__health--none .clusterlane-cq__hval{color:#94a3b8}.clusterlane-cq__delta{font-size:12.5px;font-weight:700;padding:1px 7px;border-radius:999px}.clusterlane-cq__delta--up{color:#15803d;background:#dcfce7}.clusterlane-cq__delta--down{color:#b91c1c;background:#fee2e2}.clusterlane-cq__delta--flat{color:#64748b;background:#f1f5f9}.clusterlane-cq__since{font-size:12px;color:#94a3b8}.clusterlane-cq__spark{display:block;width:100%;height:46px;overflow:visible}.clusterlane-cq__spark-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.clusterlane-cq__spark-area{opacity:.12}.clusterlane-cq__spark--good .clusterlane-cq__spark-line{stroke:#16a34a}.clusterlane-cq__spark--good .clusterlane-cq__spark-area{fill:#16a34a}.clusterlane-cq__spark--fair .clusterlane-cq__spark-line{stroke:#d97706}.clusterlane-cq__spark--fair .clusterlane-cq__spark-area{fill:#d97706}.clusterlane-cq__spark--weak .clusterlane-cq__spark-line{stroke:#dc2626}.clusterlane-cq__spark--weak .clusterlane-cq__spark-area{fill:#dc2626}.clusterlane-cq__sparkaxis{display:flex;justify-content:space-between;font-size:10.5px;color:#94a3b8;margin-top:2px}.clusterlane-cq__nohist{margin:8px 0 4px;font-size:12px;color:#94a3b8}.clusterlane-cq__tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.clusterlane-cq__tile{padding:11px 13px;min-width:0}.clusterlane-cq__tval{margin-top:3px;font-size:22px;line-height:1.15;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums}.clusterlane-cq__tval.is-good{color:#15803d}.clusterlane-cq__tval.is-fair{color:#b45309}.clusterlane-cq__tval.is-weak{color:#dc2626}.clusterlane-cq__tsub{margin-top:2px;font-size:11.5px;color:#94a3b8}.clusterlane-cq__focus{display:flex;align-items:flex-start;gap:10px;margin:12px 0 0;padding:11px 14px;border-radius:10px;background:#eff6ff;border:1px solid #bfdbfe;color:#1e3a8a;line-height:1.45}.clusterlane-cq__focus .clusterlane-g{width:18px;height:18px;margin:1px 0 0;flex-shrink:0;color:#2563eb}.clusterlane-cq__focus strong{margin-right:4px}.clusterlane-cq__focus--ok{background:#f0fdf4;border-color:#bbf7d0;color:#14532d}.clusterlane-cq__focus--ok .clusterlane-g{color:#16a34a}.clusterlane-cq__cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 22px;margin-top:4px}.clusterlane-cq__sec{margin-top:16px}.clusterlane-cq__sec h3{display:flex;align-items:center;gap:4px;margin:0 0 8px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#64748b}.clusterlane-cq__mix{display:flex;gap:2px;height:12px;border-radius:6px;overflow:hidden;background:#e2e8f0}.clusterlane-cq__seg{flex-basis:0;min-width:4px}.clusterlane-cq__seg--pillar{background:#e76c2e}.clusterlane-cq__seg--support{background:#f5c842}.clusterlane-cq__seg--light{background:#90b865}.clusterlane-cq__seg--orphan{background:#5ba4cf}.clusterlane-cq__legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin:8px 0 0;padding:0;list-style:none;font-size:12.5px;color:#475569}.clusterlane-cq__legend li{display:inline-flex;align-items:center;gap:5px;margin:0}.clusterlane-cq__legend b{color:#0f172a;font-variant-numeric:tabular-nums}.clusterlane-cq__legend li.is-zero{color:#94a3b8}.clusterlane-cq__legend li.is-zero b{color:#94a3b8;font-weight:500}.clusterlane-cq__dl{margin:0;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}.clusterlane-cq__dl>div{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:8px 12px;border-top:1px solid #f1f5f9}.clusterlane-cq__dl>div:first-child{border-top:0}.clusterlane-cq__dl dt{color:#475569}.clusterlane-cq__dl dt a{color:#2563eb;text-decoration:none}.clusterlane-cq__dl dt a:hover{text-decoration:underline}.clusterlane-cq__dl dd{margin:0;font-weight:700;color:#0f172a;font-variant-numeric:tabular-nums;white-space:nowrap}.clusterlane-cq__dl dd.is-weak{color:#dc2626}.clusterlane-cq__meta{margin:0;line-height:1.6;color:#475569}.clusterlane-cq__meta .is-weak{color:#dc2626;font-weight:600}.clusterlane-cq__meta .is-fair{color:#b45309;font-weight:600}.clusterlane-cq__meta a{display:inline-flex;align-items:center;gap:3px;margin-left:4px;color:#2563eb;font-weight:600;text-decoration:none;white-space:nowrap}.clusterlane-cq__meta a .clusterlane-g{width:13px;height:13px;margin:0}.clusterlane-cq__meta--ok{display:flex;align-items:center;gap:6px;color:#15803d}.clusterlane-cq__meta--ok .clusterlane-g{width:15px;height:15px;margin:0}.clusterlane-cq__muted{margin:0;color:#94a3b8;line-height:1.5}.clusterlane-cq__list{background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}.clusterlane-cq__art{display:flex;align-items:center;gap:10px;padding:8px 12px;color:#1e293b;text-decoration:none;border-top:1px solid #f1f5f9}.clusterlane-cq__list .clusterlane-cq__art:first-child{border-top:0}.clusterlane-cq__sec>.clusterlane-cq__art{background:#fff;border:1px solid #e2e8f0;border-radius:10px;margin-bottom:6px}.clusterlane-cq__art:focus-visible,.clusterlane-cq__art:hover{background:#f8fafc;color:#1d4ed8;outline:0}.clusterlane-cq__art:focus-visible{box-shadow:inset 0 0 0 2px #2563eb}.clusterlane-cq__arttext{flex:1;min-width:0}.clusterlane-cq__artname{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-weight:600;line-height:1.35;overflow-wrap:anywhere}.clusterlane-cq__artwhy{display:flex;align-items:center;gap:5px;margin-top:2px;font-size:11.5px;color:#64748b}.clusterlane-cq__go{flex-shrink:0;color:#cbd5e1}.clusterlane-cq__go .clusterlane-g{width:14px;height:14px;margin:0}.clusterlane-cq__art:hover .clusterlane-cq__go{color:#2563eb}.clusterlane-cq__more{margin:8px 0 0;font-size:12px;color:#64748b}@media (max-width:782px){.clusterlane-cq-overlay{padding:0;align-items:stretch}.clusterlane-cq{max-width:none;max-height:none;height:100%;border-radius:0}.clusterlane-cq__head{padding:12px 12px 12px 16px}.clusterlane-cq__title{font-size:17px}.clusterlane-cq__pos{display:none}.clusterlane-cq__body{padding:14px 16px 18px}.clusterlane-cq__cols,.clusterlane-cq__top{grid-template-columns:1fr}.clusterlane-cq__keys{display:none}.clusterlane-cq__foot{justify-content:flex-end;padding-bottom:calc(12px + env(safe-area-inset-bottom))}.clusterlane-cq__open{flex:1;justify-content:center}}@media (max-width:782px){.clusterlane-gap-item{flex-direction:column;align-items:stretch;gap:8px}.clusterlane-gap-item-actions{flex-shrink:1}.clusterlane-split-head{flex-wrap:wrap;row-gap:8px}.clusterlane-split-note{flex:1 1 100%}.clusterlane-ls-modal-overlay{padding:0;align-items:stretch}.clusterlane-ls-modal{max-height:none;height:100%;border-radius:0}.clusterlane-ls-modal-header{padding:14px 16px}.clusterlane-ls-modal-body{flex-direction:column;overflow-y:auto;-webkit-overflow-scrolling:touch}.clusterlane-ls-modal-controls-col{order:1;flex:none;min-width:0;max-width:none;padding:14px 16px 18px;overflow:visible}.clusterlane-ls-modal-preview-col{order:2;flex:none;padding:14px 16px 20px;overflow:visible;border-right:0;border-top:1px solid #eef0f3}.clusterlane-ls-preview-frame{min-height:0}.clusterlane-ls-preview-iframe{height:420px}.clusterlane-ls-modal-controls-col .clusterlane-ls-apply-final-btn{margin-top:18px}}.clusterlane-settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:8px}@media (max-width:900px){.clusterlane-settings-grid{grid-template-columns:1fr}}.clusterlane-checkbox-list{max-height:320px;overflow-y:auto;border:1px solid #eee;border-radius:8px;padding:4px 8px;margin-top:10px}.clusterlane-checkbox-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:10px;padding:7px 4px;border-bottom:1px solid #f3f3f3;font-size:.88rem}.clusterlane-checkbox-row:last-child{border-bottom:none}.clusterlane-checkbox-count{color:#94a3b8;font-size:.78rem;white-space:nowrap}.clusterlane-settings-actions{margin-top:10px;display:flex;gap:8px}.clusterlane-overlap-legend{font-size:.78rem;color:#888;margin:6px 0 4px}.clusterlane-overlap-badge{display:inline-block;width:9px;height:9px;border-radius:50%;vertical-align:middle}.clusterlane-overlap-low{background:#10b981}.clusterlane-overlap-mid{background:#f59e0b}.clusterlane-overlap-high{background:#ef4444}.clusterlane-type-explainer{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:28px}@media (max-width:900px){.clusterlane-type-explainer{grid-template-columns:1fr}}.clusterlane-type-card{display:flex;align-items:flex-start;gap:12px;background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px}.clusterlane-type-icon{font-size:22px;line-height:1;flex-shrink:0;margin-top:2px}.clusterlane-type-card strong{display:block;font-size:13px;color:#0f172a;margin-bottom:3px}.clusterlane-type-card p{margin:0;font-size:12px;color:#64748b;line-height:1.4}.clusterlane-section-heading{font-size:1.15rem;font-weight:800;letter-spacing:-.01em;color:#0f172a;margin:0 0 12px}.clusterlane-section-sub{font-size:12px;font-weight:400;color:#94a3b8;margin-left:4px}.clusterlane-section-divider{border:none;border-top:1px solid #e2e8f0;margin:32px 0 24px}.clusterlane-lt-row{border:1px solid #eef0f3;border-radius:10px;padding:14px 16px;margin-bottom:14px}.clusterlane-lt-row-top{display:flex;gap:10px;align-items:center;margin-bottom:10px}.clusterlane-lt-name-input{flex:1;font-weight:600}.clusterlane-lt-remove{background:0 0;border:1px solid #e2e8f0;border-radius:6px;width:30px;height:30px;cursor:pointer;color:#94a3b8;flex-shrink:0}.clusterlane-lt-remove:hover{color:#b32d2e;border-color:#fca5a5;background:#fef2f2}.clusterlane-lt-preview-label{font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;font-weight:700;color:#94a3b8;margin-bottom:6px}.clusterlane-lt-live-preview{margin-bottom:10px}.clusterlane-lt-live-preview blockquote{margin:0!important}.clusterlane-lt-transfer{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:16px;padding-top:14px;border-top:1px solid #eef0f3}.clusterlane-lt-transfer__label{font-size:13px;color:#64748b}.clusterlane-lt-transfer__status{font-size:13px;color:#15803d}.clusterlane-lt-transfer__status.is-error{color:#b32d2e}.clusterlane-lt-row.is-imported{border-color:#86efac;background:#f0fdf4}.clusterlane-lt-edit-toggle{background:0 0;border:none;color:#2271b1;font-size:.8rem;font-weight:600;cursor:pointer;padding:4px 0}
Index: /clusterlane/tags/1.3.2/admin/js/article-inspector.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/article-inspector.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/article-inspector.js	(revision 3751027)
@@ -0,0 +1,1745 @@
+/**
+ * Article Inspector — admin/js/article-inspector.js
+ *
+ * Handles autocomplete search, data rendering, and mini TMS scan
+ * for the Article Inspector page.
+ *
+ * Depends on: clusterlaneData (restUrl, nonce) injected via wp_localize_script.
+ */
+( function () {
+    'use strict';
+    // "?" tooltip markup (admin/js/clusterlane-tips.js); '' if unavailable.
+    const tip = ( key ) => ( typeof window.clusterlaneTip === 'function' ? window.clusterlaneTip( key ) : '' );
+
+    /* ------------------------------------------------------------------
+       DOM references
+    ------------------------------------------------------------------ */
+    const searchInput   = document.getElementById( 'clusterlane-ai-post-search' );
+    const searchResults = document.getElementById( 'clusterlane-ai-search-results' );
+    const inspector     = document.getElementById( 'clusterlane-ai-inspector' );
+
+    const elPostHeader  = document.getElementById( 'clusterlane-ai-head' );
+    const elTitle       = document.getElementById( 'clusterlane-ai-title' );
+    const elEditLink    = document.getElementById( 'clusterlane-ai-edit-link' );
+    const elViewLink    = document.getElementById( 'clusterlane-ai-view-link' );
+    const elPostMeta    = document.getElementById( 'clusterlane-ai-post-meta' );
+    const elMetrics     = document.getElementById( 'clusterlane-ai-metrics' );
+    const elClusters    = document.getElementById( 'clusterlane-ai-clusters' );
+    const elInboundList = document.getElementById( 'clusterlane-ai-inbound-list' );
+    const elOutboundList= document.getElementById( 'clusterlane-ai-outbound-list' );
+    const elInboundCnt  = document.getElementById( 'clusterlane-ai-inbound-count' );
+    const elOutboundCnt = document.getElementById( 'clusterlane-ai-outbound-count' );
+    const elLinksSummary= document.getElementById( 'clusterlane-ai-links-summary' );
+    const improveList   = document.getElementById( 'clusterlane-ai-improve-list' );
+    const elVitals      = document.getElementById( 'clusterlane-ai-vitals' );
+    const graphTabsEl   = document.getElementById( 'clusterlane-ai-graph-tabs' );
+    let   vitalsClusters = [];
+    let   lastLinkCounts = null;
+    let   graphData      = null;   // post data the graph tabs are built from
+    const GRAPH_TAB_KEY  = 'clusterlane_ai_graph_tab';
+    const improveSum    = document.getElementById( 'clusterlane-ai-improve-sum' );
+
+    const aiAnalysisCard    = document.getElementById( 'clusterlane-ai-analysis-card' );
+    const aiAnalysisBtn     = document.getElementById( 'clusterlane-ai-analysis-btn' );
+    const aiAnalysisRefresh = document.getElementById( 'clusterlane-ai-analysis-refresh' );
+    const aiAnalysisStatus  = document.getElementById( 'clusterlane-ai-analysis-status' );
+    const aiAnalysisResults = document.getElementById( 'clusterlane-ai-analysis-results' );
+
+    const graphCard    = document.getElementById( 'clusterlane-ai-graph-card' );
+    const graphEl      = document.getElementById( 'clusterlane-ai-ego-graph' );
+
+    /* ── Click-to-zoom ────────────────────────────────────────────────
+       vis-network grabs the mouse wheel as soon as zoomView is on, which
+       traps page scrolling on any inline graph. Start with zoom OFF; a
+       mousedown inside the graph enables it, leaving (or clicking
+       elsewhere) disables it. Drag-to-pan works regardless. */
+    function clusterlaneClickToZoom( network, container, hintText ) {
+        if ( ! network || ! container ) return;
+        if ( ! container.querySelector( '.clusterlane-graph-zoom-hint' ) ) {
+            const hint = document.createElement( 'div' );
+            hint.className = 'clusterlane-graph-zoom-hint';
+            hint.textContent = hintText || 'Click to enable zoom · drag to pan';
+            container.appendChild( hint );
+        }
+        // Always act on the CURRENT network (a new one is built per cluster/article).
+        container.__clusterlaneNet = network;
+        container.__clusterlaneZoomOn = null;
+        const setZoom = on => {
+            if ( container.__clusterlaneZoomOn === on ) return;   // no change → no vis redraw
+            container.__clusterlaneZoomOn = on;
+            try { container.__clusterlaneNet.setOptions( { interaction: { zoomView: on } } ); } catch ( e ) {}
+            container.classList.toggle( 'is-zoom-active', on );
+        };
+        setZoom( false );
+        if ( container.__clusterlaneZoomBound ) return;
+        container.__clusterlaneZoomBound = true;
+        container.addEventListener( 'mousedown', () => setZoom( true ) );
+        container.addEventListener( 'mouseleave', () => setZoom( false ) );
+        // Clicking elsewhere turns zoom off AFTER that click has completed.
+        // (Doing it on mousedown re-rendered the graph in the middle of the
+        // click; content below the graph could shift under the pointer, so the
+        // click never reached e.g. the cluster rows below an open panel.)
+        document.addEventListener( 'click', e => { if ( ! container.contains( e.target ) ) setTimeout( () => setZoom( false ), 0 ); } );
+    }
+    const graphOverlay = document.getElementById( 'clusterlane-ai-graph-overlay' );
+    const graphLegend  = document.getElementById( 'clusterlane-ai-graph-legend' );
+    let   egoNetwork   = null;
+
+    if ( ! searchInput ) return; // not on this page
+
+    const REST  = clusterlaneData.restUrl;
+    const NONCE = clusterlaneData.nonce;
+
+    let currentPostId   = null;
+    let lastPost        = null; // post object of the open article (search preview re-render)
+    let lastSeo         = null; // its SEO data, incl. the AI review (0.98.196)
+    let searchTimer     = null;
+
+    /* ------------------------------------------------------------------
+       Recent articles — localStorage
+    ------------------------------------------------------------------ */
+    const RECENT_KEY = 'clusterlane_inspector_recent';
+    const RECENT_MAX = 6;
+
+    function getRecent() {
+        try { return JSON.parse( localStorage.getItem( RECENT_KEY ) ) || []; }
+        catch ( e ) { return []; }
+    }
+
+    function saveRecent( id, title, url, thumb ) {
+        const list = getRecent().filter( r => r.id !== id );
+        list.unshift( { id, title, url, thumb: thumb || '' } );
+        try { localStorage.setItem( RECENT_KEY, JSON.stringify( list.slice( 0, RECENT_MAX ) ) ); }
+        catch ( e ) { /* storage full — silently skip */ }
+        renderRecentPanel();
+    }
+
+    // Recent panel — injected into the search card, shown when no article is loaded.
+    const searchCard = document.getElementById( 'clusterlane-ai-recent-slot' ) || document.querySelector( '.clusterlane-panel-card' );
+    const recentPanel = document.createElement( 'div' );
+    recentPanel.id = 'clusterlane-ai-recent-panel';
+    recentPanel.style.marginTop = '14px';
+    if ( searchCard ) searchCard.appendChild( recentPanel );
+
+    function renderRecentPanel() {
+        if ( ! recentPanel ) return;
+        const list = getRecent();
+        if ( ! list.length || inspector.style.display !== 'none' ) {
+            recentPanel.innerHTML = '';
+            return;
+        }
+        recentPanel.innerHTML =
+            `<p class="clusterlane-ai-recent-label">Recently inspected</p>
+             <div class="clusterlane-ai-recent-list">
+                ${ list.map( r =>
+                    `<button class="clusterlane-ai-recent-item" data-id="${ r.id }" data-title="${ esc( r.title ) }">
+                        ${ clusterlaneThumbsOn() ? clusterlaneThumb( r.thumb || '', r.title, '', 'sm' ) : `<span class="clusterlane-ai-recent-icon">${ clusterlaneGlyph( 'search' ) }</span>` }
+                        <span class="clusterlane-ai-recent-title">${ esc( truncTitle( r.title ) ) }</span>
+                     </button>`
+                ).join( '' ) }
+             </div>`;
+
+        recentPanel.querySelectorAll( '.clusterlane-ai-recent-item' ).forEach( btn => {
+            btn.onclick = () => selectPost( parseInt( btn.dataset.id, 10 ), btn.dataset.title );
+        } );
+    }
+
+    // Init on page load.
+    renderRecentPanel();
+
+    // Prune "Recently inspected": drop articles that left Clusterlane's scope
+    // (deleted, unpublished or password-protected since they were opened).
+    ( function pruneRecent() {
+        const list = getRecent();
+        if ( ! list.length ) return;
+        apiFetch( 'inspector/articles?per_page=50&include=' + list.map( r => r.id ).join( ',' ) )
+            .then( d => {
+                const keep = new Set( ( d.items || [] ).map( i => i.id ) );
+                const next = list.filter( r => keep.has( r.id ) );
+                if ( next.length === list.length ) return;
+                try { localStorage.setItem( RECENT_KEY, JSON.stringify( next ) ); } catch ( e ) {}
+                renderRecentPanel();
+            } )
+            .catch( () => {} );
+    } )();
+
+    function forgetRecent( id ) {
+        const next = getRecent().filter( r => r.id !== id );
+        try { localStorage.setItem( RECENT_KEY, JSON.stringify( next ) ); } catch ( e ) {}
+    }
+
+    /* ------------------------------------------------------------------
+       Articles to review — GET inspector/articles (paged on the server)
+    ------------------------------------------------------------------ */
+    const browse = document.getElementById( 'clusterlane-ai-browse' );
+    if ( browse ) {
+        const ab = {
+            q: '', cluster: '', role: '', meta: '', lang: '', sort: 'attention', order: 'desc', page: 1,
+            rows:    document.getElementById( 'clusterlane-ab-rows' ),
+            pager:   document.getElementById( 'clusterlane-ab-pager' ),
+            summary: document.getElementById( 'clusterlane-ab-summary' ),
+            qInput:  document.getElementById( 'clusterlane-ab-q' ),
+            select:  document.getElementById( 'clusterlane-ab-cluster' ),
+            metaSel: document.getElementById( 'clusterlane-ab-meta' ),
+            roles:   document.getElementById( 'clusterlane-ab-roles' ),
+            roleHead: document.getElementById( 'clusterlane-ab-role-head' ),
+            req: 0,
+        };
+        const ROLE_LABEL = { orphan: 'Orphan', light: 'Light', support: 'Support', pillar: 'Pillar' };
+        const scoreClass = n => n >= 80 ? 'good' : n >= 60 ? 'fair' : 'weak';
+        const ago = iso => {
+            if ( ! iso ) return '—';
+            const d = ( Date.now() - new Date( iso.replace( ' ', 'T' ) + 'Z' ).getTime() ) / 1000;
+            if ( d < 3600 )   return Math.max( 1, Math.round( d / 60 ) ) + ' min ago';
+            if ( d < 86400 )  return Math.round( d / 3600 ) + ' h ago';
+            if ( d < 86400 * 60 ) return Math.round( d / 86400 ) + ' d ago';
+            return iso.slice( 0, 10 );
+        };
+
+        // Export CSV (0.98.166): every article that matches the current search, cluster, role and meta filter.
+        const abCsv = document.getElementById( 'clusterlane-ab-csv' );
+        if ( abCsv ) abCsv.addEventListener( 'click', async () => {
+            const qs = new URLSearchParams( { export: 1, sort: ab.sort, order: ab.order } );
+            if ( ab.q )       qs.set( 'q', ab.q );
+            if ( ab.cluster ) qs.set( 'cluster', ab.cluster );
+            if ( ab.role )    qs.set( 'role', ab.role );
+            if ( ab.meta )    qs.set( 'meta', ab.meta );
+            if ( ab.lang )    qs.set( 'lang', ab.lang );
+            abCsv.disabled = true;
+            try {
+                const items = await window.clusterlaneExportAll( page => { qs.set( 'page', page ); return apiFetch( 'inspector/articles?' + qs.toString() ); }, abCsv );
+                const data = { items };
+                window.clusterlaneCsv( 'clusterlane-articles', [
+                    [ 'ID', r => r.id ], [ 'Title', r => r.title ], [ 'Type', r => r.type ], [ 'URL', r => r.url ],
+                    [ 'Cluster', r => r.cluster_label ], [ 'Clusters', r => r.cluster_count ], [ 'Role', r => ROLE_LABEL[ r.role ] || r.role ],
+                    [ 'Score', r => r.score ], [ 'Links in', r => r.inbound ], [ 'Links out', r => r.outbound ],
+                    [ 'Words', r => r.words ], [ 'Last scanned', r => r.last_scanned ],
+                ].concat( document.getElementById( 'clusterlane-ab-lang' ) ? [ [ 'Language', r => r.lang ] ] : [] ), data.items || [] );
+            } catch ( e ) { window.alert( 'Could not export: ' + ( e.message || e ) ); }
+            abCsv.disabled = false;
+        } );
+
+        async function loadBrowse() {
+            const my = ++ab.req;
+            browse.classList.add( 'is-loading' );
+            const qs = new URLSearchParams( { page: ab.page, per_page: 25, sort: ab.sort, order: ab.order } );
+            if ( ab.q )       qs.set( 'q', ab.q );
+            if ( ab.cluster ) qs.set( 'cluster', ab.cluster );
+            if ( ab.role )    qs.set( 'role', ab.role );
+            if ( ab.meta )    qs.set( 'meta', ab.meta );
+            if ( ab.lang )    qs.set( 'lang', ab.lang );
+            let data;
+            try { data = await apiFetch( 'inspector/articles?' + qs.toString() ); }
+            catch ( e ) { if ( my === ab.req ) { ab.rows.innerHTML = `<tr><td colspan="7" class="clusterlane-browse__empty">${ esc( e.message || 'Could not load articles.' ) }</td></tr>`; browse.classList.remove( 'is-loading' ); } return; }
+            if ( my !== ab.req ) return; // a newer request is on its way
+            browse.classList.remove( 'is-loading' );
+
+            // Role chips: counts for the current cluster/search.
+            ab.roles.querySelectorAll( 'button[data-role]' ).forEach( b => {
+                const r = b.dataset.role, em = b.querySelector( 'em' );
+                if ( em ) em.textContent = r ? ( data.counts[ r ] || 0 ) : '';
+                b.classList.toggle( 'is-active', r === ab.role );
+                b.setAttribute( 'aria-pressed', r === ab.role ? 'true' : 'false' );
+            } );
+            // Meta filter: counts for the current cluster/search/role.
+            if ( ab.metaSel && data.meta_counts ) {
+                const mc = data.meta_counts;
+                ab.metaSel.querySelectorAll( 'option' ).forEach( o => {
+                    if ( ! o.dataset.base ) o.dataset.base = o.textContent;
+                    o.textContent = o.value ? `${ o.dataset.base } (${ mc[ o.value ] || 0 })` : o.dataset.base;
+                } );
+                ab.metaSel.classList.toggle( 'is-active', !! ab.meta );
+            }
+            if ( ab.roleHead ) ab.roleHead.textContent = data.scope === 'cluster' ? 'Role in cluster' : 'Role';
+
+            // Sort indicators.
+            browse.querySelectorAll( 'th button[data-sort]' ).forEach( b => {
+                const on = b.dataset.sort === ab.sort;
+                b.classList.toggle( 'is-sorted', on );
+                b.closest( 'th' ).setAttribute( 'aria-sort', on ? ( ab.order === 'asc' ? 'ascending' : 'descending' ) : 'none' );
+                b.dataset.dir = on ? ab.order : '';
+            } );
+
+            const orphans = data.counts.orphan || 0;
+            // Summary pill: count · sort order · orphans (a shortcut to the Orphans filter).
+            const total = parseInt( data.total, 10 ) || 0;
+            let sum = `<span class="clusterlane-browse__sum-part"><b>${ total }</b> article${ total === 1 ? '' : 's' }</span>`;
+            if ( ab.sort === 'attention' ) {
+                sum += `<span class="clusterlane-browse__sum-part clusterlane-browse__sum-part--sort"><span aria-hidden="true">↓</span> needs attention first</span>`;
+                if ( orphans && ! ab.role ) {
+                    sum += `<button type="button" class="clusterlane-browse__sum-part clusterlane-browse__sum-orphans" data-role-jump="orphan" title="Show orphans only"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span><b>${ parseInt( orphans, 10 ) }</b> orphan${ orphans === 1 ? '' : 's' }</button>`;
+                }
+            }
+            ab.summary.innerHTML = sum;
+
+            // Shortcut to SEO Titles with the same cluster, search and meta filter (0.98.154).
+            const seoLink = document.getElementById( 'clusterlane-ab-seo' );
+            if ( seoLink ) {
+                const u = new URL( seoLink.href, location.href );
+                [ [ 'cluster', ab.cluster ], [ 'q', ab.q ], [ 'meta', ab.meta ] ].forEach( ( [ k, v ] ) => { if ( v ) u.searchParams.set( k, v ); else u.searchParams.delete( k ); } );
+                seoLink.href = u.toString();
+            }
+
+            if ( ! data.items.length ) {
+                ab.rows.innerHTML = `<tr><td colspan="7" class="clusterlane-browse__empty">No articles match these filters.</td></tr>`;
+            } else {
+                ab.rows.innerHTML = data.items.map( it => {
+                    const more = it.cluster_count > 1 ? ` <span class="clusterlane-browse__more">+${ it.cluster_count - 1 }</span>` : '';
+                    return `<tr data-id="${ it.id }" data-title="${ esc( it.title ) }">
+                        <td class="clusterlane-browse__col-title"><div class="clusterlane-browse__titlecell">${ clusterlaneThumb( it.thumb || '', it.title, it.role, 'md' ) }<div>
+                            <button type="button" class="clusterlane-browse__open">${ esc( truncTitle( it.title ) ) }${ it.type === 'product' ? '<span class="clusterlane-lang-tag clusterlane-type-tag">Product</span>' : '' }${ it.lang ? `<span class="clusterlane-lang-tag">${ esc( it.lang ) }</span>` : '' }</button>
+                            <span class="clusterlane-browse__cluster">${ it.cluster_label ? esc( it.cluster_label ) + more : 'No cluster' }</span>
+                        </div></div></td>
+                        <td>${ it.role ? `<span class="clusterlane-role-pill clusterlane-role-pill--${ esc( it.role ) }"><span class="clusterlane-role-dot clusterlane-role-dot--${ esc( it.role ) }"></span>${ esc( ROLE_LABEL[ it.role ] || it.role ) }</span>` : '—' }</td>
+                        <td class="num"><span class="clusterlane-browse__score clusterlane-browse__score--${ scoreClass( it.score ) }">${ it.score }</span></td>
+                        <td class="num${ it.inbound === 0 ? ' clusterlane-browse__zero' : '' }">${ it.inbound }</td>
+                        <td class="num clusterlane-browse__opt">${ it.outbound }</td>
+                        <td class="num clusterlane-browse__opt">${ it.words.toLocaleString() }</td>
+                        <td class="clusterlane-browse__opt clusterlane-browse__muted">${ esc( ago( it.last_scanned ) ) }</td>
+                    </tr>`;
+                } ).join( '' );
+            }
+
+            // Pager.
+            const from = data.total ? ( data.page - 1 ) * data.per_page + 1 : 0;
+            const to   = Math.min( data.total, data.page * data.per_page );
+            ab.pager.innerHTML = data.pages > 1
+                ? `<span class="clusterlane-browse__muted">${ from }–${ to } of ${ data.total }</span>
+                   <div class="clusterlane-browse__pages">
+                     <button type="button" data-page="${ data.page - 1 }" ${ data.page <= 1 ? 'disabled' : '' } aria-label="Previous page">‹ Prev</button>
+                     <span>Page ${ data.page } of ${ data.pages }</span>
+                     <button type="button" data-page="${ data.page + 1 }" ${ data.page >= data.pages ? 'disabled' : '' } aria-label="Next page">Next ›</button>
+                   </div>`
+                : ( data.total ? `<span class="clusterlane-browse__muted">${ data.total } article${ data.total === 1 ? '' : 's' }</span>` : '' );
+        }
+
+        // Row → inspect (whole row clickable; the title is the keyboard target).
+        ab.rows.addEventListener( 'click', e => {
+            const tr = e.target.closest( 'tr[data-id]' );
+            if ( ! tr || e.target.closest( '.clusterlane-tip' ) ) return;
+            selectPost( parseInt( tr.dataset.id, 10 ), tr.dataset.title );
+            window.scrollTo( { top: 0, behavior: 'smooth' } );
+        } );
+        ab.pager.addEventListener( 'click', e => {
+            const b = e.target.closest( 'button[data-page]' );
+            if ( ! b || b.disabled ) return;
+            ab.page = parseInt( b.dataset.page, 10 ); loadBrowse();
+            browse.scrollIntoView( { block: 'start', behavior: 'smooth' } );
+        } );
+        browse.querySelector( 'thead' ).addEventListener( 'click', e => {
+            const b = e.target.closest( 'button[data-sort]' );
+            if ( ! b ) return;
+            const key = b.dataset.sort;
+            if ( ab.sort === key ) ab.order = ab.order === 'asc' ? 'desc' : 'asc';
+            else { ab.sort = key; ab.order = key === 'title' ? 'asc' : ( key === 'score' || key === 'inbound' ) ? 'asc' : 'desc'; }
+            ab.page = 1; loadBrowse();
+        } );
+        ab.roles.addEventListener( 'click', e => {
+            const b = e.target.closest( 'button[data-role]' );
+            if ( ! b ) return;
+            ab.role = b.dataset.role; ab.page = 1; loadBrowse();
+        } );
+        ab.summary.addEventListener( 'click', e => {
+            const b = e.target.closest( 'button[data-role-jump]' );
+            if ( ! b ) return;
+            ab.role = b.dataset.roleJump; ab.page = 1; loadBrowse();
+        } );
+        let qTimer;
+        ab.qInput.addEventListener( 'input', () => {
+            clearTimeout( qTimer );
+            qTimer = setTimeout( () => { ab.q = ab.qInput.value.trim(); ab.page = 1; loadBrowse(); }, 250 );
+        } );
+        ab.select.addEventListener( 'change', () => { ab.cluster = ab.select.value; ab.role = ''; ab.page = 1; loadBrowse(); } );
+        const abLang = document.getElementById( 'clusterlane-ab-lang' ); // multilingual sites only
+        if ( abLang ) abLang.addEventListener( 'change', () => { ab.lang = abLang.value; ab.page = 1; loadBrowse(); } );
+        if ( ab.metaSel ) ab.metaSel.addEventListener( 'change', () => { ab.meta = ab.metaSel.value; ab.page = 1; loadBrowse(); } );
+
+        // Cluster options (active clusters, by label).
+        apiFetch( 'clusters' ).then( d => {
+            const list = ( Array.isArray( d ) ? d : ( d.clusters || [] ) )
+                .filter( c => String( c.is_active ) === '1' )
+                .sort( ( a, b ) => a.cluster_label.localeCompare( b.cluster_label ) );
+            ab.select.insertAdjacentHTML( 'beforeend', list.map( c =>
+                `<option value="${ esc( c.cluster_key ) }">${ esc( c.cluster_label ) } (${ parseInt( c.total_members, 10 ) || 0 })</option>` ).join( '' ) );
+        } ).catch( () => {} );
+
+        // Filters from the URL (links from the Report page): ?role=orphan, ?meta=missing|weak.
+        const qp = new URLSearchParams( location.search );
+        if ( ROLE_LABEL[ qp.get( 'role' ) ] ) ab.role = qp.get( 'role' );
+        if ( [ 'missing', 'weak' ].includes( qp.get( 'meta' ) ) ) {
+            ab.meta = qp.get( 'meta' );
+            if ( ab.metaSel ) ab.metaSel.value = ab.meta;
+        }
+
+        // Only load when the list is visible (no article opened via ?post_id=).
+        if ( ! qp.get( 'post_id' ) ) loadBrowse();
+    }
+
+    // Auto-select article if post_id is passed as a query param
+    // (e.g. from Cluster Inspector's 🔎 link).
+    ( function() {
+        const params = new URLSearchParams( document.location.search );
+        const pid    = parseInt( params.get( 'post_id' ), 10 );
+        if ( pid ) {
+            // Fetch the post title first so the search field shows the right label.
+            apiFetch( 'inspector/post/' + pid )
+                .then( data => {
+                    const title = data?.post?.title || String( pid );
+                    selectPost( pid, title );
+                } )
+                .catch( () => selectPost( pid, String( pid ) ) );
+        }
+    } )();
+
+    /* ------------------------------------------------------------------
+       Fetch helper
+    ------------------------------------------------------------------ */
+    async function apiFetch( path, opts = {} ) {
+        const res = await fetch( REST + path, {
+            headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json', ...( opts.headers || {} ) },
+            ...opts,
+        } );
+        if ( ! res.ok ) {
+            // Surface the server's message (e.g. page-builder refusal) when there is one.
+            const data = await res.json().catch( () => ( {} ) );
+            throw new Error( ( data && data.error ) || ( 'HTTP ' + res.status ) );
+        }
+        return res.json();
+    }
+
+    /* ------------------------------------------------------------------
+       Autocomplete search
+    ------------------------------------------------------------------ */
+    searchInput.addEventListener( 'input', () => {
+        clearTimeout( searchTimer );
+        const q = searchInput.value.trim();
+        if ( q.length < 2 ) {
+            hideResults();
+            // Show recent panel again if inspector is hidden (user cleared the search).
+            if ( inspector.style.display === 'none' ) renderRecentPanel();
+            return;
+        }
+        searchTimer = setTimeout( () => doSearch( q ), 280 );
+    } );
+
+    searchInput.addEventListener( 'keydown', e => {
+        if ( e.key === 'Escape' ) hideResults();
+    } );
+
+    document.addEventListener( 'click', e => {
+        if ( ! searchInput.contains( e.target ) && ! searchResults.contains( e.target ) ) {
+            hideResults();
+        }
+    } );
+
+    async function doSearch( q ) {
+        try {
+            const results = await apiFetch( 'inspector/search?q=' + encodeURIComponent( q ) );
+            renderResults( results );
+        } catch ( err ) {
+            hideResults();
+        }
+    }
+
+    function renderResults( items ) {
+        if ( ! items.length ) { hideResults(); return; }
+        searchResults.innerHTML = items.map( item =>
+            `<div class="clusterlane-ls-result-item" data-id="${ item.id }" tabindex="0">
+                ${ clusterlaneThumb( item.thumb || '', item.title, '', 'sm' ) }<span class="clusterlane-ls-result-title">${ esc( truncTitle( item.title ) ) }</span>
+             </div>`
+        ).join( '' );
+        searchResults.style.display = 'block';
+
+        searchResults.querySelectorAll( '.clusterlane-ls-result-item' ).forEach( el => {
+            el.onclick = () => selectPost( parseInt( el.dataset.id, 10 ), el.querySelector( '.clusterlane-ls-result-title' ).textContent );
+            el.onkeydown = e => { if ( e.key === 'Enter' ) el.click(); };
+        } );
+    }
+
+    function hideResults() {
+        searchResults.style.display = 'none';
+        searchResults.innerHTML     = '';
+    }
+
+    /* ------------------------------------------------------------------
+       Select a post
+    ------------------------------------------------------------------ */
+    async function selectPost( postId, title ) {
+        hideResults();
+        searchInput.value = title;
+        currentPostId     = postId;
+
+        // Hide recent panel while inspector is open.
+        recentPanel.innerHTML = '';
+
+        // Show inspector panel with loading state.
+        inspector.style.display = 'block';
+        elTitle.textContent     = title;
+        elPostMeta.innerHTML    = '<span style="color:#94a3b8;">Loading…</span>';
+        elMetrics.innerHTML     = '<span style="color:#94a3b8;">Loading…</span>';
+        elClusters.innerHTML    = '<span style="color:#94a3b8;">Loading…</span>';
+        elLinksSummary.innerHTML= '';
+        elInboundList.innerHTML = '';
+        elOutboundList.innerHTML= '';
+        elInboundCnt.textContent= '0';
+        elOutboundCnt.textContent='0';
+        if ( improveList ) improveList.innerHTML = '<p class="clusterlane-ai-improve__loading">Checking the article…</p>';
+        if ( improveSum )  improveSum.textContent = '';
+
+        // Reset AI analysis panel.
+        if ( aiAnalysisStatus )  aiAnalysisStatus.textContent = '';
+        if ( aiAnalysisResults ) aiAnalysisResults.innerHTML  = '';
+        if ( aiAnalysisRefresh ) aiAnalysisRefresh.style.display = 'none';
+
+        try {
+            const data = await apiFetch( 'inspector/post/' + postId );
+            renderPostData( data );
+            loadImprove( data, postId );
+            // Save to recent now that we have the URL.
+            saveRecent( postId, data.post?.title || title, data.post?.url || '', data.post?.thumb || '' );
+        } catch ( err ) {
+            console.error( 'Clusterlane Inspector error:', err );
+            forgetRecent( postId ); // e.g. now password-protected — don't offer it again
+            elPostMeta.innerHTML = '<span style="color:#fca5a5;">' + esc( err.message ) + '</span>';
+            return;
+        }
+
+        // Auto-load cached AI analysis if available — cached_only so no AI call is made.
+        // (Free edition has no AI analysis — the button shows an example instead.)
+        if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) return;
+        window.clusterlanePro.articleAiAutoload( { postId, NONCE, REST, inspector, renderAiAnalysis } ); // Pro: cached AI analysis, if any
+    }
+
+    /* ------------------------------------------------------------------
+       Search preview — the SEO title and meta description as a search
+       result (SeoMeta: SEO plugin or custom/ACF fields). Read-only.
+    ------------------------------------------------------------------ */
+    function serpPreview( post, seo ) {
+        if ( ! seo ) return '';
+        let host = '', crumbs = '';
+        try {
+            const u = new URL( post.url );
+            host   = u.host.replace( /^www\./, '' );
+            crumbs = u.pathname.split( '/' ).filter( Boolean ).map( decodeURIComponent ).join( ' › ' );
+        } catch ( e ) {}
+        const site = ( window.clusterlaneStickyBar && clusterlaneStickyBar.siteName ) || host;
+        const tLen = [ ...( seo.title || '' ) ].length, dLen = [ ...( seo.description || '' ) ].length;
+
+        const chip = ( cls, text, title ) => `<span class="clusterlane-serp__chip clusterlane-serp__chip--${ cls }"${ title ? ` title="${ esc( title ) }"` : '' }>${ text }</span>`;
+        const tChip = seo.title_status === 'missing'
+            ? chip( 'bad', 'No SEO title', 'Search engines will use the post title.' )
+            : seo.title_status === 'long'
+            ? chip( 'warn', `Title ${ tLen }/${ seo.title_max }`, 'Longer than search engines usually show — the end is cut off.' )
+            : chip( 'ok', `Title ${ tLen }` + ( seo.title_from === 'post_title' ? ' · post title' : '' ), seo.title_from === 'post_title' ? 'No SEO title set — the post title is used.' : '' );
+        const dChip = {
+            ok:      chip( 'ok',   `Description ${ dLen }` + ( seo.description_from === 'excerpt' ? ' · excerpt' : '' ), seo.description_from === 'excerpt' ? 'No meta description set — the excerpt is used.' : '' ),
+            short:   chip( 'warn', `Description ${ dLen } · short`, `Under ${ seo.desc_min } characters — there is room to say more.` ),
+            long:    chip( 'warn', `Description ${ dLen }/${ seo.desc_max }`, 'Longer than search engines usually show — the end is cut off.' ),
+            missing: chip( 'bad',  'No description', 'Search engines will pick text from the page themselves.' ),
+        }[ seo.description_status ] || '';
+        const canManage = parseInt( clusterlaneData.canManage, 10 ) === 1 && clusterlaneData.settingsUrl;
+        const src = canManage
+            ? `<a class="clusterlane-serp__src" href="${ esc( clusterlaneData.settingsUrl + ( clusterlaneData.settingsUrl.indexOf( '?' ) > -1 ? '&' : '?' ) + 'tab=content#clusterlane-set-seo' ) }" title="Change where Clusterlane reads SEO fields">${ esc( seo.source ) }</a>`
+            : `<span class="clusterlane-serp__src">${ esc( seo.source ) }</span>`;
+
+        return `<div class="clusterlane-serp" aria-label="Search result preview">
+            <div class="clusterlane-serp__bar"><span class="clusterlane-serp__label">Search preview</span>${ tChip }${ dChip }${ src }<button type="button" class="clusterlane-serp__ai" title="Suggest a better SEO title and meta description">${ clusterlaneGlyph( 'sparkle' ) }Suggest</button><button type="button" class="clusterlane-serp__ai" data-seo-check title="AI rates the title and description against the article, 1–5 — nothing is written">${ clusterlaneGlyph( 'sparkle' ) }${ seo.check && ! seo.check.stale ? 'Check again' : 'Check' }</button></div>
+            <div class="clusterlane-serp__site"><span class="clusterlane-serp__fav" aria-hidden="true">${ esc( ( site || '?' ).trim().charAt( 0 ).toUpperCase() ) }</span>
+                <span><span class="clusterlane-serp__name">${ esc( site ) }</span><span class="clusterlane-serp__url">${ esc( host ) }${ crumbs ? ' › ' + esc( crumbs ) : '' }</span></span></div>
+            <div class="clusterlane-serp__title">${ esc( seo.title || post.title || '' ) }</div>
+            ${ seo.description
+                ? `<div class="clusterlane-serp__desc">${ esc( seo.description ) }</div>`
+                : `<div class="clusterlane-serp__desc clusterlane-serp__desc--none">No meta description — search engines pick text from the page themselves.</div>` }
+            ${ serpOriginal( seo.original ) }
+            ${ serpReview( seo.check ) }
+        </div>`;
+    }
+
+    // Changed by Clusterlane → Restore original (0.98.197). Free: restore is not an AI feature.
+    function serpOriginal( o ) {
+        if ( ! o || ! o.differs ) return '';
+        const v = x => x === null || x === undefined ? null : ( x === '' ? '(empty)' : x );
+        const tip = [ 'Original before Clusterlane' + ( o.at ? ' (' + o.at.slice( 0, 10 ) + ')' : '' ) + ':', v( o.title ) !== null ? 'Title: ' + v( o.title ) : '', v( o.description ) !== null ? 'Description: ' + v( o.description ) : '' ].filter( Boolean ).join( '\n' );
+        return `<div class="clusterlane-serp__orig">Changed by Clusterlane — the original is kept. <button type="button" class="clusterlane-seo-ai__link" data-seo-restore title="${ esc( tip ) }">Restore original</button></div>`;
+    }
+
+    // The AI review under the preview (0.98.196) — same as in SEO Titles.
+    function serpReview( c, example ) {
+        if ( ! c ) return '';
+        const part = ( label, n, issue ) => `<span class="clusterlane-serp__rv clusterlane-serp__rv--${ n <= 2 ? 'bad' : n === 3 ? 'warn' : 'ok' }"><b>${ label } ${ n }/5</b>${ issue ? ' — ' + esc( issue ) : '' }</span>`;
+        return `<div class="clusterlane-serp__review${ c.stale ? ' is-stale' : '' }"><span class="clusterlane-serp__rv-tag">${ clusterlaneGlyph( 'sparkle' ) }${ example ? 'Example review' : 'AI review' }</span>${ part( 'Title', c.t, c.ti ) }${ part( 'Description', c.d, c.di ) }${ c.stale ? '<span class="clusterlane-serp__rv-stale">of an earlier version — check again</span>' : '' }</div>`;
+    }
+
+    // "+N terms" in the hero reveals the terms that are not clusters.
+    if ( elPostMeta ) elPostMeta.addEventListener( 'click', e => {
+        const b = e.target.closest( '.clusterlane-ai-terms-more' );
+        if ( ! b ) return;
+        const rest = b.previousElementSibling;
+        const open = rest.hidden;
+        rest.hidden = ! open;
+        b.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
+        b.textContent = open ? 'Show less' : b.dataset.label;
+    } );
+
+    /* ------------------------------------------------------------------
+       AI suggestions for SEO title + meta description (Pro).
+       Rendering lives here (free); the calls are window.clusterlanePro.seo*().
+       Free edition: the Lisbon demo example, frozen.
+       Apply writes only to custom fields (server: SeoMeta::can_write()).
+    ------------------------------------------------------------------ */
+    const seoAi = { data: null, undoUntil: 0 };
+    const seoPanel = () => document.getElementById( 'clusterlane-seo-ai' );
+    const chars = s => [ ...( s || '' ) ].length;
+
+    function seoCount( kind, text, sfx ) {
+        const n = chars( text );
+        if ( kind === 't' ) {
+            const extra = sfx && sfx.mode === 'auto' ? chars( sfx.text ) : 0;
+            const total = n + extra;
+            const cls = n === 0 ? 'bad' : total > 60 ? 'warn' : 'ok';
+            return `<span class="clusterlane-seo-ai__count clusterlane-seo-ai__count--${ cls }">${ extra ? n + ' + ' + extra + ' = ' : '' }${ total }/60</span>`;
+        }
+        const cls = n === 0 ? 'bad' : ( n < 70 || n > 160 ) ? 'warn' : 'ok';
+        return `<span class="clusterlane-seo-ai__count clusterlane-seo-ai__count--${ cls }">${ n } · 70–160</span>`;
+    }
+
+    function renderSeoSuggestions( data, opts = {} ) {
+        const panel = seoPanel();
+        if ( ! panel ) return;
+        seoAi.data = data;
+        const ex  = !! opts.example;
+        const sfx = data.suffix || { mode: 'none', text: '' };
+        const when = data.generated_at ? ' · ' + esc( timeAgo( data.generated_at ) ) : '';
+        const cards = ( data.suggestions || [] ).map( ( sg, i ) => `
+            <div class="clusterlane-seo-ai__card" data-i="${ i }">
+                <label class="clusterlane-seo-ai__lbl">Title <span data-count="t">${ seoCount( 't', sg.title, sfx ) }</span></label>
+                <input type="text" class="clusterlane-seo-ai__t" value="${ esc( sg.title ) }" aria-label="Suggested SEO title ${ i + 1 }">
+                <label class="clusterlane-seo-ai__lbl">Description <span data-count="d">${ seoCount( 'd', sg.description ) }</span></label>
+                <textarea rows="3" class="clusterlane-seo-ai__d" aria-label="Suggested meta description ${ i + 1 }">${ esc( sg.description ) }</textarea>
+                <div class="clusterlane-seo-ai__actions">
+                    ${ data.can_write
+                        ? `<button type="button" class="clusterlane-seo-ai__btn clusterlane-seo-ai__btn--primary" data-act="apply">Apply</button><span class="clusterlane-seo-ai__hint">Editable before you apply · the original is kept</span>`
+                        : `<button type="button" class="clusterlane-seo-ai__btn" data-act="copy-t">Copy title</button><button type="button" class="clusterlane-seo-ai__btn" data-act="copy-d">Copy description</button>` }
+                </div>
+            </div>` ).join( '' );
+
+        panel.hidden = false;
+        panel.innerHTML = `
+            <div class="clusterlane-seo-ai__head">
+                <strong>${ clusterlaneGlyph( 'sparkle' ) }Suggestions</strong>
+                <span class="clusterlane-seo-ai__meta">${ data.cached ? 'Cached' : 'New' }${ when }${ sfx.mode === 'include' ? ' · with “' + esc( sfx.text.trim() ) + '”' : '' }</span>
+                ${ ex ? '' : '<button type="button" class="clusterlane-seo-ai__link" data-act="regen">' + clusterlaneGlyph( 'sparkle' ) + 'Regenerate</button>' }
+                <button type="button" class="clusterlane-seo-ai__close" data-act="close" aria-label="Close suggestions">×</button>
+            </div>
+            <div class="clusterlane-seo-ai__status" aria-live="polite"></div>
+            ${ ! data.can_write && data.write_note ? `<p class="clusterlane-seo-ai__note">${ esc( data.write_note ) }${ lastPost && lastPost.edit_url ? ` <a href="${ esc( lastPost.edit_url ) }" target="_blank" rel="noopener">Edit post →</a>` : '' }</p>` : '' }
+            <div class="clusterlane-seo-ai__cards">${ cards }</div>`;
+
+        if ( ex ) {
+            panel.insertAdjacentHTML( 'afterbegin', clusterlaneProBanner( 'AI SEO suggestions' ) );
+            clusterlaneFreezeExample( panel );
+        }
+    }
+
+    function seoStatus( html, kind ) {
+        const el = seoPanel() && seoPanel().querySelector( '.clusterlane-seo-ai__status' );
+        if ( ! el ) return;
+        el.className = 'clusterlane-seo-ai__status' + ( kind ? ' clusterlane-seo-ai__status--' + kind : '' );
+        el.innerHTML = html;
+    }
+
+    function seoSuggest( force ) {
+        const panel = seoPanel();
+        if ( ! panel || ! currentPostId ) return;
+        if ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) {
+            const ex = clusterlaneProExample( 'seo_meta' );
+            if ( ex ) renderSeoSuggestions( ex, { example: true } );
+            return;
+        }
+        panel.hidden = false;
+        panel.innerHTML = `<div class="clusterlane-seo-ai__loading">${ clusterlaneGlyph( 'sparkle' ) }${ force ? 'Writing new suggestions…' : 'Writing suggestions…' }</div>`;
+        const postId = currentPostId;
+        window.clusterlanePro.seoSuggest( {
+            postId, force, apiFetch,
+            isCurrent: () => postId === currentPostId,
+            render: data => renderSeoSuggestions( data ),
+            renderError: msg => { panel.innerHTML = `<div class="clusterlane-seo-ai__error">${ esc( msg ) } <button type="button" class="clusterlane-seo-ai__close" data-act="close" aria-label="Close">×</button></div>`; },
+        } );
+    }
+
+    // Restore original / undo the restore (0.98.197) — free routes, not AI.
+    async function seoRestore( btn ) {
+        const undo = btn.hasAttribute( 'data-seo-unrestore' );
+        btn.disabled = true; btn.textContent = undo ? 'Undoing…' : 'Restoring…';
+        try {
+            const res = await apiFetch( undo ? 'seo/undo' : 'seo/restore', { method: 'POST', body: JSON.stringify( { post_id: currentPostId } ) } );
+            seoRefresh( res );
+            const serp = elPostMeta.querySelector( '.clusterlane-serp' );
+            if ( serp && ! undo ) serp.insertAdjacentHTML( 'beforeend', `<div class="clusterlane-serp__orig">${ clusterlaneGlyph( 'check' ) }Original restored. <button type="button" class="clusterlane-seo-ai__link" data-seo-unrestore>Undo</button></div>` );
+        } catch ( err ) {
+            btn.disabled = false; btn.textContent = undo ? 'Undo' : 'Restore original';
+            const serp = elPostMeta.querySelector( '.clusterlane-serp' );
+            if ( serp ) serp.insertAdjacentHTML( 'beforeend', `<div class="clusterlane-serp__review clusterlane-serp__review--err">${ esc( err.message || 'Could not restore.' ) }</div>` );
+        }
+    }
+
+    // Check with AI for this article (0.98.196): POST seo/check with its ID — the same
+    // review as SEO Titles. "Check again" forces a new call; a stale review is redone anyway.
+    async function seoCheck( btn ) {
+        const serp = elPostMeta.querySelector( '.clusterlane-serp' );
+        if ( ! serp || ! currentPostId ) return;
+        const old = serp.querySelector( '.clusterlane-serp__review' );
+        if ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) {
+            const ex  = clusterlaneProExample( 'seo_titles' );
+            const one = ex && ( ex.items || [] ).find( it => it.check && Math.min( it.check.t, it.check.d ) <= 3 );
+            if ( one ) { const html = serpReview( one.check, true ) + `<p class="clusterlane-serp__rv-pro">${ clusterlaneProBanner( 'AI review of SEO titles' ) }</p>`; if ( old ) old.remove(); const p = serp.querySelector( '.clusterlane-serp__rv-pro' ); if ( p ) p.remove(); serp.insertAdjacentHTML( 'beforeend', html ); }
+            return;
+        }
+        const postId = currentPostId, force = /again/i.test( btn.textContent );
+        btn.disabled = true; btn.innerHTML = clusterlaneGlyph( 'sparkle' ) + 'Checking…';
+        try {
+            const res = await apiFetch( 'seo/check', { method: 'POST', body: JSON.stringify( { post_ids: [ postId ], force } ) } );
+            if ( postId !== currentPostId ) return;
+            const c = ( res.results || {} )[ postId ];
+            if ( ! c ) throw new Error( 'No review came back — try again.' );
+            if ( lastSeo ) { lastSeo.check = c; const cur = elPostMeta.querySelector( '.clusterlane-serp' ); if ( cur ) cur.outerHTML = serpPreview( lastPost, lastSeo ); }
+        } catch ( err ) {
+            btn.disabled = false; btn.innerHTML = clusterlaneGlyph( 'sparkle' ) + 'Check';
+            if ( old ) old.remove();
+            serp.insertAdjacentHTML( 'beforeend', `<div class="clusterlane-serp__review clusterlane-serp__review--err">${ esc( err.message || 'The review failed.' ) }</div>` );
+        }
+    }
+
+    // After Apply/Undo: fresh preview + metrics (the server rescans the post).
+    function seoRefresh( res ) {
+        if ( res.seo ) lastSeo = res.seo; // apply/undo clear the review on the server too
+        if ( res.seo && lastPost ) {
+            const old = elPostMeta.querySelector( '.clusterlane-serp' );
+            if ( old ) old.outerHTML = serpPreview( lastPost, res.seo );
+        }
+        if ( res.metrics ) {
+            if ( lastLinkCounts ) { res.metrics.inbound_links = lastLinkCounts.in; res.metrics.outbound_links = lastLinkCounts.out_posts; }
+            renderMetrics( res.metrics );
+        }
+    }
+
+    async function seoCopy( text, btn ) {
+        try { await navigator.clipboard.writeText( text ); }
+        catch ( e ) {
+            const ta = document.createElement( 'textarea' ); ta.value = text; document.body.appendChild( ta ); ta.select();
+            try { document.execCommand( 'copy' ); } catch ( e2 ) {} ta.remove();
+        }
+        const label = btn.textContent; btn.textContent = 'Copied ✓';
+        setTimeout( () => { btn.textContent = label; }, 1400 );
+    }
+
+    if ( elPostMeta ) {
+        elPostMeta.addEventListener( 'click', e => {
+            const rst = e.target.closest( '[data-seo-restore], [data-seo-unrestore]' );
+            if ( rst ) { seoRestore( rst ); return; }
+            const chk = e.target.closest( '[data-seo-check]' );
+            if ( chk ) { seoCheck( chk ); return; }
+            if ( e.target.closest( '.clusterlane-serp__ai' ) ) { seoSuggest( false ); return; }
+            const b = e.target.closest( '#clusterlane-seo-ai [data-act]' );
+            if ( ! b || b.disabled ) return;
+            const act  = b.dataset.act;
+            const card = b.closest( '.clusterlane-seo-ai__card' );
+            const t = card ? card.querySelector( '.clusterlane-seo-ai__t' ).value.trim() : '';
+            const d = card ? card.querySelector( '.clusterlane-seo-ai__d' ).value.trim() : '';
+            if ( act === 'close' )  { const p = seoPanel(); p.hidden = true; p.innerHTML = ''; return; }
+            if ( act === 'regen' )  { seoSuggest( true ); return; }
+            if ( act === 'copy-t' ) { seoCopy( t, b ); return; }
+            if ( act === 'copy-d' ) { seoCopy( d, b ); return; }
+            if ( ! window.clusterlanePro ) return;
+            if ( act === 'apply' ) {
+                b.disabled = true; b.textContent = 'Applying…';
+                window.clusterlanePro.seoApply( {
+                    postId: currentPostId, title: t, description: d, apiFetch,
+                    onDone: res => {
+                        seoRefresh( res );
+                        seoPanel().querySelectorAll( '.clusterlane-seo-ai__card' ).forEach( c => c.classList.toggle( 'is-applied', c === card ) );
+                        b.disabled = false; b.textContent = 'Apply';
+                        const until = new Date( ( res.undo_until || 0 ) * 1000 );
+                        const hhmm  = isNaN( until ) ? '' : until.toLocaleTimeString( [], { hour: '2-digit', minute: '2-digit' } );
+                        const where = ( res.written || [] ).map( k => '<code>' + esc( k ) + '</code>' ).join( ' and ' );
+                        seoStatus( `${ clusterlaneGlyph( 'check' ) }Applied to ${ where || 'the SEO fields' }. <button type="button" class="clusterlane-seo-ai__link" data-act="undo">Undo</button>${ hhmm ? ' <span class="clusterlane-seo-ai__meta">until ' + esc( hhmm ) + '</span>' : '' }`, 'ok' );
+                    },
+                    onError: msg => { b.disabled = false; b.textContent = 'Apply'; seoStatus( esc( msg ), 'bad' ); },
+                } );
+                return;
+            }
+            if ( act === 'undo' ) {
+                b.disabled = true;
+                window.clusterlanePro.seoUndo( {
+                    postId: currentPostId, apiFetch,
+                    onDone: res => {
+                        seoRefresh( res );
+                        seoPanel().querySelectorAll( '.clusterlane-seo-ai__card' ).forEach( c => c.classList.remove( 'is-applied' ) );
+                        seoStatus( 'Undone — the previous title and description are back.', '' );
+                    },
+                    onError: msg => { b.disabled = false; seoStatus( esc( msg ), 'bad' ); },
+                } );
+            }
+        } );
+        // Live length counters while editing a suggestion.
+        elPostMeta.addEventListener( 'input', e => {
+            const f = e.target.closest( '.clusterlane-seo-ai__t, .clusterlane-seo-ai__d' );
+            if ( ! f ) return;
+            const card = f.closest( '.clusterlane-seo-ai__card' );
+            const kind = f.classList.contains( 'clusterlane-seo-ai__t' ) ? 't' : 'd';
+            const slot = card.querySelector( `[data-count="${ kind }"]` );
+            if ( slot ) slot.innerHTML = seoCount( kind, f.value, ( seoAi.data && seoAi.data.suffix ) || null );
+        } );
+    }
+
+    /* ------------------------------------------------------------------
+       Render post data
+    ------------------------------------------------------------------ */
+    function renderPostData( data ) {
+        try {
+        const post           = data.post            || {};
+        const metrics        = data.metrics         || null;
+        const clusters       = Array.isArray( data.clusters )      ? data.clusters      : [];
+        const inbound_links  = Array.isArray( data.inbound_links ) ? data.inbound_links : [];
+        const outbound_links = Array.isArray( data.outbound_links )? data.outbound_links: [];
+
+        // Header
+        elTitle.textContent    = post.title || '';
+        elEditLink.href        = post.edit_url || '#';
+        elViewLink.href        = post.url || '#';
+
+        // Terms: only those that are clusters are shown; the rest fold into "+N".
+        const clusterKeys = new Set( clusters.map( c => c.key ) );
+        const allTerms  = [ ...( post.categories || [] ), ...( post.tags || [] ) ];
+        const isCluster = t => clusterKeys.has( t.taxonomy + '_' + t.id );
+        const pill = t => `<span class="clusterlane-ai-tax-pill ${ t.taxonomy === 'category' ? 'clusterlane-ai-tax-pill--cat' : 'clusterlane-ai-tax-pill--tag' }">${ esc( t.name ) }</span>`;
+        const shown = allTerms.filter( isCluster ), rest = allTerms.filter( t => ! isCluster( t ) );
+        const termsHtml = ( shown.length || rest.length )
+            ? `<div class="clusterlane-ai-terms">${ shown.map( pill ).join( ' ' ) }${
+                rest.length ? ` <span class="clusterlane-ai-terms-rest" hidden>${ rest.map( pill ).join( ' ' ) } </span><button type="button" class="clusterlane-ai-terms-more" aria-expanded="false" data-label="+${ rest.length } ${ rest.length === 1 ? 'term' : 'terms' }">+${ rest.length } ${ rest.length === 1 ? 'term' : 'terms' }</button>` : '' }</div>`
+            : '';
+
+        elPostMeta.innerHTML =
+            `<span style="color:#64748b;font-size:12px;">
+                Published: <strong>${ esc( post.date ) }</strong>
+                &nbsp;·&nbsp; Modified: <strong>${ esc( post.modified ) }</strong>
+             </span>
+             ${ termsHtml }
+             ${ serpPreview( post, ( lastSeo = data.seo ) ) }
+             <div class="clusterlane-seo-ai" id="clusterlane-seo-ai" hidden></div>`;
+
+        // Featured image banner — shown above title row if image exists.
+        if ( elPostHeader ) {
+            if ( post.featured_image_url ) {
+                elPostHeader.classList.add( 'clusterlane-inspector-head--has-image' );
+                elPostHeader.style.setProperty( '--clusterlane-header-img', `url(${ JSON.stringify( post.featured_image_url ) })` );
+            } else {
+                elPostHeader.classList.remove( 'clusterlane-inspector-head--has-image' );
+                elPostHeader.style.removeProperty( '--clusterlane-header-img' );
+            }
+        }
+
+        vitalsClusters = clusters;
+        // One count for links everywhere: articles only (category/tag pages
+        // are listed separately in the Links card).
+        lastLinkCounts = data.link_counts || null;
+        if ( metrics && lastLinkCounts ) {
+            metrics.inbound_links  = lastLinkCounts.in;
+            metrics.outbound_links = lastLinkCounts.out_posts;
+        }
+        renderMetrics( metrics );
+        lastPost = post;
+
+        // Links summary — guard against undefined (scan may not have run yet)
+        const inbound  = Array.isArray( inbound_links )  ? inbound_links  : [];
+        const outbound = Array.isArray( outbound_links ) ? outbound_links : [];
+
+        // Counts live in the vitals row; here: a note on links to category/tag pages.
+        if ( elLinksSummary ) {
+            const terms = Array.isArray( data.outbound_terms ) ? data.outbound_terms : [];
+            elLinksSummary.innerHTML = terms.length
+                ? `<p class="clusterlane-ai-links-terms">Also links to ${ terms.length === 1 ? 'a category/tag page' : terms.length + ' category/tag pages' }: ${ terms.map( t => t.url ? `<a href="${ esc( t.url ) }" target="_blank">${ esc( t.name ) }</a>` : esc( t.name ) ).join( ', ' ) } — not counted as article links.</p>`
+                : '';
+        }
+
+        // Inbound list
+        if ( elInboundCnt )  elInboundCnt.textContent  = lastLinkCounts ? lastLinkCounts.in : inbound.length;
+        if ( elInboundList ) elInboundList.innerHTML    = inbound.length
+            ? inbound.map( linkRow ).join( '' )
+            : `<li class="clusterlane-ai-no-data">No posts link here yet.</li>`;
+
+        // Outbound list
+        if ( elOutboundCnt )  elOutboundCnt.textContent  = lastLinkCounts ? lastLinkCounts.out_posts : outbound.length;
+        if ( elOutboundList ) elOutboundList.innerHTML    = outbound.length
+            ? outbound.map( linkRow ).join( '' )
+            : `<li class="clusterlane-ai-no-data">This post links to no other posts yet.</li>`;
+
+        // Ego graph — inside try so clusters variable is in scope
+        renderGraphTabs( data );
+
+        // Clusters
+        if ( clusters.length ) {
+            elClusters.innerHTML = clusters.map( c => {
+                const hc    = c.health_score >= 70 ? '#16a34a' : c.health_score >= 50 ? '#ca8a04' : '#dc2626';
+                const cls   = c.content_class ? `<span class="clusterlane-ai-role-pill clusterlane-ai-role-pill--${ esc( c.content_class ) }">${ esc( c.content_class ) }</span>` : '';
+                const arch  = c.archetype ? `<span class="clusterlane-archetype-pill clusterlane-archetype-${ esc( c.archetype.toLowerCase().replace( /\s+/g, '-' ) ) }" style="font-size:10px;">${ esc( c.archetype ) }</span>` : '<span style="font-size:11px;color:#94a3b8;">Unclassified</span>';
+                const typei = clusterlaneGlyph( clusterlaneType( c.type ).glyph );
+                const ciUrl = ( clusterlaneData.clusterInspectorUrl || '' ) + '&open=' + encodeURIComponent( c.key || '' );
+                return `<a href="${ esc( ciUrl ) }" class="clusterlane-ai-cluster-row clusterlane-ai-cluster-row--stacked">
+                    <span class="clusterlane-ai-cluster-line">
+                        <span class="clusterlane-ai-cluster-type">${ typei }</span>
+                        <span class="clusterlane-ai-cluster-name">${ esc( truncTitle( c.label ) ) }</span>
+                        <span class="clusterlane-ai-cluster-health" style="color:${ hc };">${ c.health_score }%</span>
+                        <span class="clusterlane-ai-cluster-arrow">→</span>
+                    </span>
+                    <span class="clusterlane-ai-cluster-line clusterlane-ai-cluster-line--meta">
+                        ${ cls }
+                        ${ arch }
+                        ${ c.percentile !== null ? `<span class="clusterlane-ai-cluster-pct" title="Percentile in this cluster: stronger than ${ c.percentile }% of its articles">${ +c.percentile <= 0 ? 'weakest' : +c.percentile >= 100 ? 'strongest' : +c.percentile >= 50 ? 'top ' + ( 100 - c.percentile ) + '%' : 'bottom ' + c.percentile + '%' }</span>` : '' }
+                    </span>
+                </a>`;
+            } ).join( '' );
+        } else {
+            elClusters.innerHTML = `<p class="clusterlane-ai-no-data">This post doesn't belong to any active cluster. Pick its category or tag in Cluster Admin, or add it to a custom cluster.</p>`;
+        }
+
+        } catch ( renderErr ) {
+            console.error( 'Clusterlane renderPostData error:', renderErr );
+            elPostMeta.innerHTML += '<br><span style="color:#ef4444;font-size:11px;">Render error: ' + esc( renderErr.message ) + '</span>';
+        }
+    }
+
+    // One row in the Internal links lists: the row opens that article here in
+    // Article Inspector; the small arrow opens it on the site.
+    function linkRow( l ) {
+        return `<li><a class="clusterlane-ai-linkrow" href="${ esc( ( clusterlaneData.articleInspectorUrl || 'admin.php?page=clusterlane-article-inspector' ) + '&post_id=' + l.id ) }" data-inspect="${ +l.id }" data-title="${ esc( l.title ) }">${ clusterlaneThumb( l.thumb || '', l.title, '', 'md' ) }<span class="clusterlane-ai-linkrow__title">${ esc( truncTitle( l.title ) ) }</span>${ l.auto ? '<span class="clusterlane-auto-pill" title="Added by the pillar link box when the article is shown — not in the text">pillar box</span>' : ( l.template ? '<span class="clusterlane-auto-pill" title="On the published page — a list, card or related section from your theme, not in the article text">page</span>' : '' ) }</a>`
+            + `<a class="clusterlane-ai-linkrow__site" href="${ esc( l.url ) }" target="_blank" rel="noopener" title="View on the site" aria-label="View ${ esc( l.title ) } on the site">${ clusterlaneGlyph( 'arrow' ) }</a></li>`;
+    }
+    document.addEventListener( 'click', e => {
+        const a = e.target.closest( '.clusterlane-ai-linkrow[data-inspect]' );
+        if ( ! a || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ) return;
+        e.preventDefault();
+        selectPost( parseInt( a.dataset.inspect, 10 ), a.dataset.title );
+        window.scrollTo( { top: 0, behavior: 'smooth' } );
+    } );
+
+    // Vitals row under the hero: the key numbers before the checklist and AI text.
+    function renderVitals( m ) {
+        if ( ! elVitals ) return;
+        if ( ! m ) { elVitals.innerHTML = ''; elVitals.hidden = true; return; }
+        elVitals.hidden = false;
+        const band = s => s >= 80 ? 'good' : s >= 60 ? 'fair' : 'weak';
+        const cell = ( value, label, key, cls ) =>
+            `<span class="clusterlane-ai-vital${ cls ? ' clusterlane-ai-vital--' + cls : '' }"><strong>${ value }</strong><span>${ label }${ tip( key ) }</span></span>`;
+        const inb = +m.inbound_links || 0;
+        // "out" counts category/tag pages too — like the scores and roles do.
+        const terms = lastLinkCounts ? +lastLinkCounts.out_terms || 0 : 0;
+        const out = ( +m.outbound_links || 0 ) + terms;
+        const roles = ( vitalsClusters || [] ).filter( c => c.content_class );
+        const ROLE = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+        const first = roles[ 0 ];
+        const roleHtml = first
+            ? `<span class="clusterlane-ai-vital clusterlane-ai-vital--role"><span class="clusterlane-role-dot clusterlane-role-dot--${ esc( first.content_class ) }"></span><span>${ esc( ROLE[ first.content_class ] || first.content_class ) } in <strong>${ esc( truncTitle( first.label ) ) }</strong>${ roles.length > 1 ? ` <em>+${ roles.length - 1 } more</em>` : '' }${ tip( 'role' ) }</span></span>`
+            : `<span class="clusterlane-ai-vital clusterlane-ai-vital--role"><span>Not in any cluster</span></span>`;
+        elVitals.innerHTML =
+            cell( ( +m.overall_score || 0 ) + '%', 'Overall', 'overall_score', band( +m.overall_score ) )
+            + cell( ( +m.seo_score || 0 ) + '%', 'SEO', 'seo_score', band( +m.seo_score ) )
+            + cell( ( +m.structure_score || 0 ) + '%', 'Structure', 'structure_score', band( +m.structure_score ) )
+            + '<span class="clusterlane-ai-vital-sep" aria-hidden="true"></span>'
+            + cell( inb, 'in', 'inbound', inb === 0 ? 'weak' : inb <= 2 ? 'fair' : '' )
+            + ( terms
+                // Said on the row itself (0.98.185): "7 out" next to "Posts this links to 6" looked like an error.
+                ? cell( out, `out <em class="clusterlane-ai-vital-inc">incl. ${ terms } category/tag ${ terms === 1 ? 'page' : 'pages' }</em>`, 'outbound', '' )
+                : cell( out, 'out', 'outbound', out === 0 ? 'fair' : '' ) )
+            + cell( ( +m.word_count || 0 ).toLocaleString( 'en' ), 'words', 'word_count', '' )
+            + '<span class="clusterlane-ai-vital-sep" aria-hidden="true"></span>'
+            + roleHtml;
+    }
+
+    // Metrics card (also refreshed after an SEO Apply/Undo rescans the post).
+    function renderMetrics( metrics ) {
+        renderVitals( metrics );
+        if ( metrics ) {
+            const scoreColor = s => s >= 80 ? '#10b981' : s >= 60 ? '#f59e0b' : '#ef4444';
+            // Tooltip texts live in clusterlane_tip_definitions() (PHP) → clusterlaneTipsData.
+            // Scores, words and link counts are in the vitals row — this card
+            // holds the rest of the content facts.
+            const w = +metrics.word_count || 0, h2 = +metrics.h2_count || 0, img = +metrics.image_count || 0;
+            const fact = ( label, value, key, note ) => `<div class="clusterlane-ai-fact"><span class="clusterlane-ai-fact__label">${ label }${ key ? tip( key ) : '' }</span><span class="clusterlane-ai-fact__value">${ value }${ note ? ` <em>${ note }</em>` : '' }</span></div>`;
+            const ROLE = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+            elMetrics.innerHTML = `<div class="clusterlane-ai-facts">
+                ${ fact( 'H2 headings', h2, 'h2_count', w && h2 ? '1 per ' + Math.round( w / Math.max( 1, h2 ) ) + ' words' : '' ) }
+                ${ fact( 'Images', img, 'image_count', w && img ? '1 per ' + Math.round( w / img ) + ' words' : '' ) }
+                ${ metrics.content_class ? fact( 'Site-wide role', `<span class="clusterlane-role-dot clusterlane-role-dot--${ esc( metrics.content_class ) }"></span>${ esc( ROLE[ metrics.content_class ] || metrics.content_class ) }`, 'global_class' ) : '' }
+                ${ metrics.last_scanned ? fact( 'Last scanned', esc( String( metrics.last_scanned ).slice( 0, 16 ) ), '' ) : '' }
+            </div>`;
+        } else {
+            elMetrics.innerHTML = `<p class="clusterlane-ai-no-data">No scan data yet — click Scan in the bar at the top.</p>`;
+        }
+    }
+
+    // tipKey = a key in clusterlane_tip_definitions() — shared "?" tooltip (clusterlane-tips.js).
+
+    /* ------------------------------------------------------------------
+       Improve this article (0.98.097) — what to do here, most important
+       first. Computed from the article data + one Text Match scan (both
+       directions); no AI. Rows reuse the Text Match look: the sentence with
+       the exact words, Apply / Skip, then "Linked · Undo".
+    ------------------------------------------------------------------ */
+    const imp = { postId: null, data: null, out: [], rev: [], scanned: false, scanError: '' };
+    const SEV = { high: 0, medium: 1, low: 2 };
+    const ROLE_NAME = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+    const pl = ( n, one, many ) => n + ' ' + ( n === 1 ? one : many );
+    const lsUrl = ( k, id ) => clusterlaneData.linkSuggestUrl ? clusterlaneData.linkSuggestUrl + '&' + k + '=' + id : '';
+
+    async function loadImprove( data, postId ) {
+        if ( ! improveList ) return;
+        imp.postId = postId; imp.data = data; imp.out = []; imp.rev = []; imp.scanned = false; imp.scanError = '';
+        renderImprove();
+        try {
+            const d = await apiFetch( 'inspector/tms-scan', { method: 'POST', body: JSON.stringify( { post_id: postId } ) } );
+            if ( imp.postId !== postId ) return; // another article was opened meanwhile
+            imp.out = ( d.outgoing_matches || [] ).map( m => Object.assign( { status: 'open', dir: 'out' }, m ) )
+                .sort( ( a, b ) => ( ( a.target_role === 'orphan' ? 0 : 1 ) - ( b.target_role === 'orphan' ? 0 : 1 ) ) || ( ( a.target_inbound || 0 ) - ( b.target_inbound || 0 ) ) );
+            imp.rev = ( d.reverse_matches || [] ).map( m => Object.assign( { status: 'open', dir: 'rev' }, m ) );
+        } catch ( err ) {
+            if ( imp.postId !== postId ) return;
+            imp.scanError = err.message || 'Scan failed';
+        }
+        imp.scanned = true;
+        renderImprove();
+    }
+
+    function impSentence( m ) {
+        const cp = Array.from( String( m.snippet || '' ) );
+        const at = parseInt( m.hit_at, 10 ), len = parseInt( m.hit_len, 10 );
+        if ( ! ( at >= 0 && len > 0 && at + len <= cp.length ) ) return esc( m.snippet || m.match_text );
+        return esc( cp.slice( 0, at ).join( '' ) ) + '<span class="clusterlane-tm-hit">' + esc( cp.slice( at, at + len ).join( '' ) ) + '</span>' + esc( cp.slice( at + len ).join( '' ) );
+    }
+
+    // Roles without the "add links" permission can review and skip, not link.
+    const CAN_LINK = typeof window.clusterlaneCan !== 'function' || window.clusterlaneCan( 'links' );
+
+    function impRow( m, i ) {
+        const list = m.dir;
+        if ( m.status === 'linked' ) {
+            return `<div class="clusterlane-tm-row clusterlane-tm-row--linked"><div class="clusterlane-tm-row-main"><p class="clusterlane-tm-done">${ clusterlaneGlyph( 'check' ) }Linked in <strong>${ esc( m.dir === 'out' ? 'this article' : truncTitle( m.source_title ) ) }</strong> → <strong>${ esc( truncTitle( m.target_title ) ) }</strong></p></div>
+                ${ CAN_LINK ? `<div class="clusterlane-tm-row-actions"><button type="button" class="clusterlane-tm-btn" data-imp="undo" data-list="${ list }" data-i="${ i }">Undo</button></div>` : '' }</div>`;
+        }
+        const busy = m.status === 'busy';
+        const role = m.dir === 'out' && ROLE_NAME[ m.target_role ] ? ` <span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--${ m.target_role }"></span>${ ROLE_NAME[ m.target_role ] }</span>` : '';
+        const where = m.dir === 'out' ? '' : `<span class="clusterlane-tm-in">${ clusterlaneThumb( m.source_thumb || '', m.source_title, '', 'xs' ) }In ${ esc( truncTitle( m.source_title ) ) }:</span> `;
+        const to    = m.dir === 'out'
+            ? `<p class="clusterlane-tm-target">${ clusterlaneGlyph( 'arrow' ) }link to ${ clusterlaneThumb( m.target_thumb || '', m.target_title, m.target_role, 'xs' ) }<strong>${ esc( truncTitle( m.target_title ) ) }</strong>${ role }</p>`
+            : `<p class="clusterlane-tm-target">${ clusterlaneGlyph( 'arrow' ) }link to this article</p>`;
+        return `<div class="clusterlane-tm-row${ m.target_role === 'orphan' && m.dir === 'out' ? ' clusterlane-tm-row--orphan' : '' }">
+            <div class="clusterlane-tm-row-main"><p class="clusterlane-tm-sentence">${ where }${ impSentence( m ) }</p>${ to }</div>
+            <div class="clusterlane-tm-row-actions">
+                ${ CAN_LINK ? `<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-imp="apply" data-list="${ list }" data-i="${ i }"${ busy ? ' disabled' : '' }>${ busy ? 'Linking…' : 'Apply' }</button>` : '' }
+                <button type="button" class="clusterlane-tm-btn" data-imp="skip" data-list="${ list }" data-i="${ i }"${ busy ? ' disabled' : '' }>Skip</button>
+            </div></div>`;
+    }
+
+    function impRows( list, max ) {
+        const arr = imp[ list ];
+        const vis = arr.map( ( m, i ) => [ m, i ] ).filter( ( [ m ] ) => m.status !== 'skipped' && m.status !== 'done' ).slice( 0, max );
+        if ( ! vis.length ) return '';
+        const open = arr.filter( m => m.status === 'open' ).length;
+        return ( CAN_LINK ? '' : window.clusterlaneNoRightNote( 'links' ) )
+            + `<div class="clusterlane-tm-group clusterlane-ai-improve__rows">${ vis.map( ( [ m, i ] ) => impRow( m, i ) ).join( '' ) }</div>`
+            + ( open > 1 && CAN_LINK ? `<p class="clusterlane-ai-improve__more"><button type="button" class="clusterlane-tm-linkbtn" data-imp="all" data-list="${ list }">Apply all ${ open }</button>${ arr.length > max ? ` · ${ arr.length - max } more in <a href="admin.php?page=clusterlane-text-match" target="_blank">Text Match</a>` : '' }</p>` : '' );
+    }
+
+    function impBtn( label, href, glyph ) {
+        return href ? `<a class="clusterlane-tm-btn clusterlane-ai-improve__btn" href="${ esc( href ) }">${ glyph ? clusterlaneGlyph( glyph ) : '' }${ esc( label ) }</a>` : '';
+    }
+
+    // The checklist: each item = { sev, key, title, why, body }.
+    function improveItems() {
+        const d = imp.data || {}, post = d.post || {}, m = d.metrics, seo = d.seo;
+        const clusters = Array.isArray( d.clusters ) ? d.clusters : [];
+        const outIds = new Set( ( d.outbound_links || [] ).map( l => +l.id ) );
+        const items = [];
+        const openOut = imp.out.filter( x => x.status === 'open' || x.status === 'busy' || x.status === 'linked' );
+        const openRev = imp.rev.filter( x => x.status === 'open' || x.status === 'busy' || x.status === 'linked' );
+        const pending = ! imp.scanned ? '<p class="clusterlane-ai-improve__loading">Looking for mentions…</p>' : '';
+
+        if ( ! m ) {
+            items.push( { sev: 'high', key: 'scan', title: 'Not scanned yet', why: 'Run Scan in the bar at the top — the checks below need the scan data.', body: '' } );
+            return items;
+        }
+
+        // 1. Inbound links.
+        const inbound = +m.inbound_links || 0;
+        if ( inbound <= 2 || openRev.length ) {
+            const title = inbound === 0 ? 'No other article links here yet'
+                : inbound <= 2 ? `Only ${ pl( inbound, 'article links', 'articles link' ) } here`
+                : `${ pl( openRev.length, 'article mentions', 'articles mention' ) } this one without linking`;
+            const why = inbound === 0 ? 'Readers and search engines can only reach it through menus and search. A few links from related articles fix that.'
+                : inbound <= 2 ? 'A couple more links from related articles make it easier to find and show that it matters.'
+                : 'They already name it — a link is one click.';
+            const found = openRev.length ? `<p class="clusterlane-ai-improve__lead">${ pl( openRev.length, 'article mentions', 'articles mention' ) } it without linking:</p>` + impRows( 'rev', 5 ) : ( imp.scanned ? '<p class="clusterlane-ai-improve__lead">No article mentions its title without a link.</p>' : pending );
+            items.push( { sev: inbound === 0 ? 'high' : inbound <= 2 ? 'medium' : 'low', key: 'inbound', title, why,
+                body: found + `<div class="clusterlane-ai-improve__actions">${ impBtn( 'Find the best articles to link from', lsUrl( 'for', post.id ), 'link' ) }</div>` } );
+        }
+
+        // 2. Mentions in this article without a link.
+        if ( openOut.length ) {
+            items.push( { sev: 'medium', key: 'out', title: `Mentions ${ pl( openOut.length, 'other article', 'other articles' ) } without linking`,
+                why: 'The words are already there — link them and readers can follow.', body: impRows( 'out', 6 ) } );
+        } else if ( ! imp.scanned ) {
+            items.push( { sev: 'low', key: 'out-pending', title: 'Looking for unlinked mentions…', why: '', body: '' } );
+        }
+
+        // 3. Not linking to its cluster's pillar.
+        const members = d.cluster_members || {};
+        clusters.forEach( c => {
+            if ( c.content_class === 'pillar' ) return;
+            const pillars = ( members[ c.key ] || [] ).filter( x => x.content_class === 'pillar' && +x.id !== +post.id );
+            if ( ! pillars.length || pillars.some( p => outIds.has( +p.id ) ) ) return;
+            const p = pillars[ 0 ];
+            const viaMention = imp.out.some( x => +x.target_id === +p.id && x.status !== 'skipped' );
+            items.push( { sev: 'medium', key: 'pillar-' + c.key,
+                title: `Doesn’t link to the pillar of ${ truncTitle( c.label ) }`,
+                why: `<strong>${ esc( truncTitle( p.title ) ) }</strong> is the main article on this topic. A link to it ties this article into the cluster.`,
+                whyHtml: true,
+                body: viaMention ? '<p class="clusterlane-ai-improve__lead">This article already mentions it — see the unlinked mentions above.</p>'
+                    : `<div class="clusterlane-ai-improve__actions">${ impBtn( 'Write the link with AI', lsUrl( 'from', post.id ) + '&to=' + p.id, 'sparkle' ) }${ post.edit_url ? impBtn( 'Edit post', post.edit_url, 'pencil' ) : '' }</div>` } );
+        } );
+
+        // 4. Not in any cluster.
+        if ( ! clusters.length ) {
+            items.push( { sev: 'medium', key: 'cluster', title: 'Not part of any active cluster',
+                why: 'Its categories and tags aren’t used as clusters, so ' + window.clusterlaneBrandName() + ' can’t say where it belongs or what should link to it.',
+                body: parseInt( clusterlaneData.canManage, 10 ) === 1 && clusterlaneData.clusterAdminUrl ? `<div class="clusterlane-ai-improve__actions">${ impBtn( 'Choose clusters in Cluster Admin', clusterlaneData.clusterAdminUrl, 'layers' ) }</div>` : '' } );
+        }
+
+        // 5. Links nowhere.
+        if ( ( +m.outbound_links || 0 ) === 0 && imp.scanned && ! openOut.length ) {
+            items.push( { sev: 'low', key: 'outbound', title: 'Links to no other article',
+                why: 'A few links to related articles keep readers on the site and show how the topic fits together.',
+                body: `<div class="clusterlane-ai-improve__actions">${ impBtn( 'Find articles to link to', lsUrl( 'from', post.id ), 'link' ) }</div>` } );
+        }
+
+        // 6. Meta description / SEO title.
+        if ( seo ) {
+            const dn = [ ...( seo.description || '' ) ].length;
+            let t = '', why = '', sev = 'low';
+            if ( seo.description_status === 'missing' ) { t = 'No meta description'; why = 'Search engines pick a snippet from the page themselves — often not the best one.'; sev = 'medium'; }
+            else if ( seo.description_status === 'long' ) { t = `Meta description is too long (${ dn }/${ seo.desc_max })`; why = 'The end is cut off in search results.'; }
+            else if ( seo.description_status === 'short' ) { t = `Meta description is short (${ dn } characters)`; why = 'There is room to say more about what the reader gets.'; }
+            else if ( seo.title_status === 'long' ) { t = 'SEO title is too long'; why = 'The end is cut off in search results.'; }
+            if ( t ) items.push( { sev, key: 'meta', title: t, why,
+                body: `<div class="clusterlane-ai-improve__actions"><button type="button" class="clusterlane-tm-btn clusterlane-ai-improve__btn" data-imp="meta">${ clusterlaneGlyph( 'sparkle' ) }Suggest title & description</button></div>` } );
+        }
+
+        // 7. Structure (advice — needs the editor).
+        const w = +m.word_count || 0, h2 = +m.h2_count || 0, img = +m.image_count || 0;
+        const tips = [];
+        if ( post.is_product ) {
+            // A product page (WooCommerce, 0.98.172): no word-count or subheading rules for articles.
+            if ( w < 150 ) tips.push( `The product description is ${ w } words. 150 or more — what it is, what it is made of, who it is for — helps it rank and sell.` );
+            if ( ( +post.short_desc_words || 0 ) < 10 ) tips.push( 'No short description (or a very short one). It shows next to the price and in many themes’ product lists — one or two sentences.' );
+            if ( img < 2 ) tips.push( 'Fewer than two images besides the main one — add gallery images from different angles.' );
+        } else {
+        if ( w < 300 ) tips.push( `Short: ${ w } words. Articles under 300 words rarely rank or get linked to — expand it or merge it into a related article.` );
+        if ( w >= 600 && h2 < Math.floor( w / 300 ) ) tips.push( `${ pl( h2, 'subheading', 'subheadings' ) } for ${ w } words — add an H2 every 200–300 words so readers can scan.` );
+        if ( img === 0 && w >= 400 ) tips.push( 'No images — one or two relevant images make long text easier to read.' );
+        }
+        if ( tips.length ) items.push( { sev: 'low', key: 'structure', title: tips.length === 1 ? 'Structure' : `Structure: ${ tips.length } things`,
+            why: '', body: `<ul class="clusterlane-ai-improve__tips">${ tips.map( x => `<li>${ esc( x ) }</li>` ).join( '' ) }</ul>`
+                + ( post.edit_url ? `<div class="clusterlane-ai-improve__actions">${ impBtn( 'Edit post', post.edit_url, 'pencil' ) }</div>` : '' ) } );
+
+        return items.sort( ( a, b ) => SEV[ a.sev ] - SEV[ b.sev ] );
+    }
+
+    function renderImprove() {
+        if ( ! improveList ) return;
+        const items = improveItems();
+        const real  = items.filter( i => i.key !== 'out-pending' );
+        if ( improveSum ) {
+            const hi = real.filter( i => i.sev !== 'low' ).length;
+            improveSum.innerHTML = ! imp.scanned ? '' : real.length
+                ? `${ pl( real.length, 'thing', 'things' ) } to do${ hi ? ` · <strong>start with the first</strong>` : '' }`
+                : '';
+        }
+        if ( imp.scanned && ! real.length ) {
+            improveList.innerHTML = `<p class="clusterlane-ai-improve__done">${ clusterlaneGlyph( 'check' ) }Nothing urgent — this article is well linked and in good shape.</p>`;
+            return;
+        }
+        improveList.innerHTML = items.map( ( it, n ) => `
+            <div class="clusterlane-ai-improve__item clusterlane-ai-improve__item--${ it.sev }" data-key="${ esc( it.key ) }">
+                <div class="clusterlane-ai-improve__item-head">
+                    <span class="clusterlane-ai-improve__node" aria-hidden="true"></span>
+                    <strong>${ esc( it.title ) }</strong>
+                    ${ n === 0 && it.sev !== 'low' ? '<span class="clusterlane-ai-improve__first">Start here</span>' : '' }
+                </div>
+                ${ it.why ? `<p class="clusterlane-ai-improve__why">${ it.whyHtml ? it.why : esc( it.why ) }</p>` : '' }
+                ${ it.body || '' }
+            </div>` ).join( '' )
+            + ( imp.scanError ? `<p class="clusterlane-ai-improve__why">Mentions could not be checked: ${ esc( imp.scanError ) }</p>` : '' );
+    }
+
+    async function impCall( path, body ) {
+        return apiFetch( path, { method: 'POST', body: JSON.stringify( body ) } );
+    }
+    async function impApply( m ) {
+        m.status = 'busy'; renderImprove();
+        try {
+            await impCall( 'tms/apply', { source_id: parseInt( m.source_id, 10 ), match_text: m.match_text, target_url: m.target_url } );
+            m.status = 'linked';
+            if ( imp.data && imp.data.metrics ) {
+                if ( m.dir === 'rev' ) imp.data.metrics.inbound_links = ( +imp.data.metrics.inbound_links || 0 ) + 1;
+                else imp.data.metrics.outbound_links = ( +imp.data.metrics.outbound_links || 0 ) + 1;
+            }
+            setTimeout( () => { if ( m.status === 'linked' ) { m.status = 'done'; renderImprove(); } }, 8000 );
+        } catch ( err ) {
+            m.status = 'open';
+            alert( err && err.message && ! /^HTTP \d+$/.test( err.message ) ? err.message : 'Could not apply the link — the words may have changed.' );
+        }
+        renderImprove();
+    }
+
+    if ( improveList ) improveList.addEventListener( 'click', async e => {
+        const b = e.target.closest( '[data-imp]' );
+        if ( ! b || b.disabled ) return;
+        const act = b.dataset.imp, list = b.dataset.list;
+        const m = list ? imp[ list ][ parseInt( b.dataset.i, 10 ) ] : null;
+        if ( act === 'meta' ) {
+            const sb = elPostMeta && elPostMeta.querySelector( '.clusterlane-serp__ai' );
+            if ( sb ) { ( elPostHeader || sb ).scrollIntoView( { behavior: 'smooth', block: 'start' } ); sb.click(); }
+            return;
+        }
+        if ( act === 'apply' && m ) { impApply( m ); return; }
+        if ( act === 'skip' && m ) {
+            m.status = 'skipped'; renderImprove();
+            impCall( 'tms/skip', { source_id: parseInt( m.source_id, 10 ), target_id: parseInt( m.target_id, 10 ) } ).catch( () => {} );
+            return;
+        }
+        if ( act === 'undo' && m ) {
+            b.disabled = true; b.textContent = 'Undoing…';
+            try {
+                await impCall( 'tms/unlink', { source_id: parseInt( m.source_id, 10 ), match_text: m.match_text, target_url: m.target_url } );
+                m.status = 'open';
+                if ( imp.data && imp.data.metrics ) {
+                    const k = m.dir === 'rev' ? 'inbound_links' : 'outbound_links';
+                    imp.data.metrics[ k ] = Math.max( 0, ( +imp.data.metrics[ k ] || 0 ) - 1 );
+                }
+            } catch ( err ) { alert( err.message ); }
+            renderImprove();
+            return;
+        }
+        if ( act === 'all' && list ) {
+            b.disabled = true;
+            for ( const x of imp[ list ].filter( y => y.status === 'open' ) ) { await impApply( x ); }
+        }
+    } );
+
+    /* ------------------------------------------------------------------
+       Ego graph
+    ------------------------------------------------------------------ */
+
+    // Cluster colour palette — matches the dashboard archetype-ish palette
+    // but keyed by cluster index so each cluster gets its own hue.
+    const CLUSTER_PALETTE = [
+        '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6',
+        '#ef4444', '#0ea5e9', '#ec4899', '#14b8a6',
+    ];
+
+    /* ------------------------------------------------------------------
+       Article map tabs (0.98.098): "Links" (the ego graph) + one tab per
+       cluster the article belongs to — the cluster's own graph with this
+       article ringed. A tab is drawn when it is opened; the choice is kept.
+    ------------------------------------------------------------------ */
+    function renderGraphTabs( data ) {
+        graphData = data;
+        const clusters = ( Array.isArray( data.clusters ) ? data.clusters : [] ).filter( c => c.key );
+        if ( ! graphTabsEl ) { buildEgoGraph( data ); return; }
+        const hb = h => h >= 70 ? 'good' : h >= 50 ? 'fair' : 'weak';
+        graphTabsEl.innerHTML = `<button type="button" role="tab" data-tab="links">${ clusterlaneGlyph( 'link' ) }Links</button>`
+            + clusters.map( c => `<button type="button" role="tab" data-tab="${ esc( c.key ) }" title="${ esc( c.label ) }">
+                <span class="clusterlane-ai-graph-tab-node clusterlane-ai-graph-tab-node--${ hb( +c.health_score ) }" aria-hidden="true"></span>${ esc( truncTitle( c.label ) ) } <em>${ +c.health_score }%</em></button>` ).join( '' );
+        graphTabsEl.hidden = ! clusters.length; // only "Links" → no tab bar needed
+        let saved = 'links';
+        try { saved = localStorage.getItem( GRAPH_TAB_KEY ) || 'links'; } catch ( e ) {}
+        if ( saved !== 'links' && ! clusters.some( c => c.key === saved ) ) saved = 'links';
+        showGraphTab( saved, false );
+    }
+
+    function showGraphTab( tab, remember ) {
+        if ( ! graphData ) return;
+        if ( remember ) { try { localStorage.setItem( GRAPH_TAB_KEY, tab ); } catch ( e ) {} }
+        if ( graphTabsEl ) graphTabsEl.querySelectorAll( 'button[data-tab]' ).forEach( b => {
+            const on = b.dataset.tab === tab;
+            b.classList.toggle( 'is-active', on );
+            b.setAttribute( 'aria-selected', on ? 'true' : 'false' );
+        } );
+        if ( tab === 'links' ) buildEgoGraph( graphData );
+        else buildClusterGraph( graphData, tab );
+    }
+
+    if ( graphTabsEl ) graphTabsEl.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-tab]' );
+        if ( b && ! b.classList.contains( 'is-active' ) ) showGraphTab( b.dataset.tab, true );
+    } );
+
+    const ROLE_COL = {
+        pillar:  { bg: '#e76c2e', border: '#c2410c' },
+        support: { bg: '#f5c842', border: '#ca8a04' },
+        light:   { bg: '#90b865', border: '#4d7c0f' },
+        orphan:  { bg: '#5ba4cf', border: '#0369a1' },
+        hub:     { bg: '#334155', border: '#0f172a' },
+    };
+
+    async function buildClusterGraph( data, key ) {
+        if ( typeof vis === 'undefined' || ! graphEl ) return;
+        const post    = data.post || {};
+        const cluster = ( data.clusters || [] ).find( c => c.key === key ) || { label: key };
+        graphCard.style.display = 'block';
+        graphOverlay.classList.remove( 'hidden' );
+        graphOverlay.textContent = 'Loading cluster graph…';
+        if ( egoNetwork ) { egoNetwork.destroy(); egoNetwork = null; }
+
+        let g;
+        try {
+            g = await apiFetch( 'graph/' + encodeURIComponent( key ), { method: 'POST', body: '{}' } );
+        } catch ( err ) { graphOverlay.textContent = 'Could not load the cluster graph: ' + err.message; return; }
+        // Another article or tab was chosen while loading.
+        if ( graphData !== data || ! graphTabsEl || ! graphTabsEl.querySelector( `button.is-active[data-tab="${ CSS.escape( key ) }"]` ) ) return;
+        if ( ! g || ! Array.isArray( g.nodes ) || ! g.nodes.length ) { graphOverlay.textContent = 'No graph data for this cluster yet — run Scan first.'; return; }
+        if ( window.clusterlaneGraphNote ) window.clusterlaneGraphNote( graphOverlay.parentNode, g );
+
+        // Same ring layout as the Dashboard graph: pillars inside, orphans outside.
+        const raw = JSON.parse( JSON.stringify( g.nodes ) );
+        const groups = { pillar: [], support: [], light: [], orphan: [] };
+        raw.forEach( n => { if ( n.class !== 'hub' ) ( groups[ n.class ] || groups.light ).push( n ); } );
+        const R = {
+            pillar:  Math.max( 100, groups.pillar.length  * 40 ),
+            support: Math.max( 180, groups.support.length * 28 ),
+            light:   Math.max( 260, groups.light.length   * 22 ),
+            orphan:  Math.max( 340, groups.orphan.length  * 20 ),
+        };
+        Object.keys( groups ).forEach( k => groups[ k ].forEach( ( n, i ) => {
+            const a = -Math.PI / 2 + ( 2 * Math.PI * i ) / Math.max( 1, groups[ k ].length );
+            n.x = Math.cos( a ) * R[ k ]; n.y = Math.sin( a ) * R[ k ] * 0.82;
+        } ) );
+
+        const ROLE = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan', hub: 'Category hub' };
+        raw.forEach( n => {
+            const col  = ROLE_COL[ n.class ] || ROLE_COL.light;
+            const self = +n.id === +post.id;
+            const full = String( n.title || n.label || '' ).split( '\n' )[ 0 ];
+            n.fullTitle = full;
+            n.color = { background: col.bg, border: self ? '#0f172a' : col.border, highlight: { background: col.bg, border: '#0f172a' }, hover: { background: col.bg, border: '#0f172a' } };
+            n.shape = 'dot';
+            n.size  = self ? 22 : n.class === 'hub' ? 18 : 13;
+            n.borderWidth = self ? 5 : n.class === 'hub' ? 3 : 1.5;
+            n.font  = { size: self ? 13 : 11, color: self ? '#0f172a' : '#1e293b', face: 'system-ui,sans-serif', vadjust: self ? 0 : -22, strokeWidth: self ? 4 : 0, strokeColor: '#ffffff' };
+            if ( n.label ) n.label = truncTitle( self ? full : n.label );
+            if ( self && n.value ) n.value = +n.value * 1.25; // node size follows 'value' (scaling) — make this one stand out
+            if ( n.class === 'hub' ) { n.x = 0; n.y = 0; n.fixed = { x: true, y: true }; }
+            const t = document.createElement( 'div' );
+            t.className = 'clusterlane-ai-graph-tip';
+            t.innerHTML = ( n.class !== 'hub' && n.thumb && clusterlaneThumbsOn() ? `<img class="clusterlane-graph-tip-img" src="${ esc( n.thumb ) }" alt="">` : '' )
+                + `<strong>${ esc( full ) }</strong><span>${ esc( ROLE[ n.class ] || '' ) }${ n.class !== 'hub' ? ' · ' + ( +n.inbound || 0 ) + ' inbound' : '' }${ self ? ' · this article' : '' }</span>`;
+            n.title = t;
+        } );
+
+        const nodes = new vis.DataSet( raw );
+        // This article's own links (post ↔ post, not category-hub lines) drawn dark.
+        const own = e => typeof e.from === 'number' && typeof e.to === 'number' && ( +e.from === +post.id || +e.to === +post.id );
+        const edges = new vis.DataSet( ( g.edges || [] ).map( e => Object.assign( {}, e, own( e )
+            ? { color: { color: '#0f172a', highlight: '#0f172a' }, width: 2 }
+            : { color: Object.assign( { color: '#b0b7c3', highlight: '#475569' }, typeof e.color === 'object' ? { opacity: e.color.opacity } : {} ) } ) ) );
+        egoNetwork = new vis.Network( graphEl, { nodes, edges }, {
+            layout: { improvedLayout: false, randomSeed: 7 }, // same picture on every load (0.98.182)
+            edges: { arrows: { to: { enabled: true, scaleFactor: 0.7 } }, smooth: { type: 'curvedCW', roundness: 0.22 } },
+            physics: { enabled: true, minVelocity: 0.75, stabilization: { iterations: 160 },
+                       barnesHut: { gravitationalConstant: -280, centralGravity: 0.12, springLength: 160, springConstant: 0.04, damping: 0.72, avoidOverlap: 1.0 } },
+            interaction: { hover: true, dragNodes: true, dragView: true, zoomView: false, tooltipDelay: 80 },
+        } );
+        clusterlaneClickToZoom( egoNetwork, graphEl );
+        const net = egoNetwork;
+        net.on( 'beforeDrawing', ctx => {
+            [ [ R.pillar, 'rgba(231,108,46,0.06)' ], [ R.support, 'rgba(245,200,66,0.05)' ], [ R.light, 'rgba(144,184,101,0.04)' ] ].forEach( ( [ r, f ] ) => {
+                ctx.beginPath(); ctx.arc( 0, 0, r, 0, Math.PI * 2 ); ctx.fillStyle = f; ctx.fill();
+            } );
+        } );
+        net.once( 'stabilizationIterationsDone', () => {
+            net.setOptions( { physics: { enabled: false } } );
+            net.fit( { animation: { duration: 300, easingFunction: 'easeInOutQuad' } } );
+            graphOverlay.classList.add( 'hidden' );
+        } );
+        net.on( 'click', params => {
+            if ( ! params.nodes.length ) return;
+            const n = nodes.get( params.nodes[ 0 ] );
+            if ( ! n || n.class === 'hub' || +n.id === +post.id || typeof n.id !== 'number' ) return;
+            selectPost( n.id, n.fullTitle );
+            window.scrollTo( { top: 0, behavior: 'smooth' } );
+        } );
+        net.on( 'hoverNode', () => { graphEl.style.cursor = 'pointer'; } );
+        net.on( 'blurNode',  () => { graphEl.style.cursor = 'default'; } );
+
+        const counts = { pillar: groups.pillar.length, support: groups.support.length, light: groups.light.length, orphan: groups.orphan.length };
+        const ciUrl  = ( clusterlaneData.clusterInspectorUrl || '' ) + '&open=' + encodeURIComponent( key );
+        graphLegend.className = 'clusterlane-graph-legend';
+        graphLegend.innerHTML = Object.keys( counts ).filter( k => counts[ k ] ).map( k =>
+                `<span class="clusterlane-graph-legend-item"><span class="clusterlane-role-dot clusterlane-role-dot--${ k }"></span>${ ROLE[ k ] } ${ counts[ k ] }</span>` ).join( '' )
+            + `<span class="clusterlane-graph-legend-item"><span style="width:10px;height:10px;border-radius:50%;background:#fff;border:3px solid #0f172a;display:inline-block;box-sizing:border-box;"></span>This article</span>`
+            + ( clusterlaneData.clusterInspectorUrl ? `<a class="clusterlane-ai-graph-ci" href="${ esc( ciUrl ) }">Open ${ esc( truncTitle( cluster.label ) ) } in Cluster Inspector →</a>` : '' );
+    }
+
+    function buildEgoGraph( data ) {
+        if ( typeof vis === 'undefined' || ! graphEl ) return;
+
+        graphCard.style.display = 'block';
+        graphOverlay.classList.remove( 'hidden' );
+        graphOverlay.textContent = 'Building graph…';
+
+        const post           = data.post            || {};
+        const clusters       = Array.isArray( data.clusters )      ? data.clusters      : [];
+        const cluster_members= data.cluster_members || {};
+        const inbound_links  = Array.isArray( data.inbound_links ) ? data.inbound_links : [];
+        const outbound_links = Array.isArray( data.outbound_links )? data.outbound_links: [];
+
+        // --- Build node + edge sets ---
+        const nodes = new vis.DataSet();
+        const edges = new vis.DataSet();
+        const seen  = new Set();
+
+        // Helper — add a node once.
+        const addNode = ( id, label, options ) => {
+            if ( seen.has( id ) ) return;
+            seen.add( id );
+            const displayLabel = truncTitle( label );
+            // HTML tooltip (thumbnail + title); fullTitle keeps the text for clicks.
+            const tipEl = document.createElement( 'div' );
+            tipEl.className = 'clusterlane-ai-graph-tip';
+            tipEl.innerHTML = ( options && options.thumb && clusterlaneThumbsOn() ? `<img class="clusterlane-graph-tip-img" src="${ esc( options.thumb ) }" alt="">` : '' ) + `<strong>${ esc( displayLabel ) }</strong>`;
+            nodes.add( { id, label: truncate( displayLabel, 28 ), title: tipEl, fullTitle: displayLabel, ...options } );
+        };
+
+        // Centre node — the inspected article.
+        addNode( post.id, post.title, {
+            thumb: post.thumb || '',
+            shape: 'dot',
+            size: 26,
+            color: { background: '#0f172a', border: '#fff',
+                     highlight: { background: '#1e293b', border: '#fff' } },
+            font:  { color: '#fff', size: 13, bold: true },
+            fixed: true,
+            x: 0, y: 0,
+        } );
+
+        // Cluster members — one colour per cluster.
+        clusters.forEach( ( cluster, ci ) => {
+            const colour = CLUSTER_PALETTE[ ci % CLUSTER_PALETTE.length ];
+            const members = cluster_members[ cluster.key ] || [];
+            members.forEach( m => {
+                addNode( m.id, m.title, {
+                    thumb: m.thumb || '',
+                    shape: 'dot',
+                    size:  m.content_class === 'pillar' ? 18 : m.content_class === 'support' ? 14 : 10,
+                    color: { background: colour, border: '#fff',
+                             highlight: { background: colour, border: '#334155' } },
+                    font:  { color: '#1e293b', size: 11 },
+                    cluster_key: cluster.key,
+                    post_url: m.url,
+                } );
+                edges.add( {
+                    from: post.id, to: m.id,
+                    color: { color: colour + '55', highlight: colour },
+                    width: 1.5,
+                    dashes: true,
+                    arrows: '',
+                } );
+            } );
+        } );
+
+        // Inbound links — posts that link TO this one (orange, thick ring).
+        inbound_links.forEach( l => {
+            addNode( l.id, l.title, {
+                thumb: l.thumb || '',
+                shape: 'dot', size: 14,
+                borderWidth: 4,
+                color: { background: '#fff7ed', border: '#f97316',
+                         highlight: { background: '#ffedd5', border: '#ea580c' } },
+                font:  { color: '#1e293b', size: 11, background: 'rgba(249,115,22,0.15)' },
+                post_url: l.url,
+            } );
+            edges.add( {
+                from: l.id, to: post.id,
+                color: { color: '#f9731688', highlight: '#f97316' },
+                width: 2,
+                arrows: { to: { enabled: true, scaleFactor: .6 } },
+            } );
+        } );
+
+        // Outbound links — posts this one links TO (teal, thick ring).
+        outbound_links.forEach( l => {
+            addNode( l.id, l.title, {
+                thumb: l.thumb || '',
+                shape: 'dot', size: 14,
+                borderWidth: 4,
+                color: { background: '#f0fdfa', border: '#14b8a6',
+                         highlight: { background: '#ccfbf1', border: '#0d9488' } },
+                font:  { color: '#1e293b', size: 11, background: 'rgba(20,184,166,0.15)' },
+                post_url: l.url,
+            } );
+            edges.add( {
+                from: post.id, to: l.id,
+                color: { color: '#14b8a688', highlight: '#14b8a6' },
+                width: 2,
+                arrows: { to: { enabled: true, scaleFactor: .6 } },
+            } );
+        } );
+
+        // --- Render ---
+        if ( egoNetwork ) {
+            egoNetwork.destroy();
+            egoNetwork = null;
+        }
+
+        const options = {
+            layout: { randomSeed: 7 }, // same picture on every load (0.98.182)
+            physics: {
+                solver: 'forceAtlas2Based',
+                forceAtlas2Based: {
+                    gravitationalConstant: -60,
+                    centralGravity: 0.012,
+                    springLength: 130,
+                    springConstant: 0.05,
+                    damping: 0.4,
+                },
+                stabilization: { iterations: 180, updateInterval: 30 },
+            },
+            interaction: {
+                tooltipDelay: 100,
+                hover: true,
+                zoomView: false,
+            },
+            nodes: {
+                borderWidth: 2,
+                shadow: { enabled: true, size: 6, x: 2, y: 2, color: 'rgba(0,0,0,.08)' },
+            },
+            edges: {
+                smooth: { type: 'continuous' },
+            },
+        };
+
+        egoNetwork = new vis.Network( graphEl, { nodes, edges }, options );
+        clusterlaneClickToZoom( egoNetwork, graphEl );
+
+        egoNetwork.once( 'stabilizationIterationsDone', () => {
+            graphOverlay.classList.add( 'hidden' );
+        } );
+
+        // Click node → load that article in Inspector.
+        egoNetwork.on( 'click', params => {
+            if ( ! params.nodes.length ) return;
+            const nodeId = params.nodes[0];
+            if ( nodeId === post.id ) return;
+            const node = nodes.get( nodeId );
+            if ( node && node.fullTitle ) {
+                selectPost( nodeId, node.fullTitle );
+                window.scrollTo( { top: 0, behavior: 'smooth' } );
+            }
+        } );
+
+        // Cursor pointer on hover.
+        egoNetwork.on( 'hoverNode', () => { graphEl.style.cursor = 'pointer'; } );
+        egoNetwork.on( 'blurNode',  () => { graphEl.style.cursor = 'default'; } );
+
+        // --- Legend — pill-style matching cluster graph ---
+        const legendItems = [];
+        clusters.forEach( ( cl, ci ) => {
+            const colour = CLUSTER_PALETTE[ ci % CLUSTER_PALETTE.length ];
+            legendItems.push(
+                `<span class="clusterlane-graph-legend-item">
+                    <span style="width:9px;height:9px;border-radius:50%;background:${ colour };display:inline-block;flex-shrink:0;"></span>
+                    ${ esc( cl.label ) }
+                </span>`
+            );
+        } );
+        if ( inbound_links.length ) {
+            legendItems.push(
+                `<span class="clusterlane-graph-legend-item">
+                    <span style="width:9px;height:9px;border-radius:50%;background:#fff7ed;border:2px solid #f97316;display:inline-block;flex-shrink:0;box-sizing:border-box;"></span>
+                    Links here
+                </span>`
+            );
+        }
+        if ( outbound_links.length ) {
+            legendItems.push(
+                `<span class="clusterlane-graph-legend-item">
+                    <span style="width:9px;height:9px;border-radius:50%;background:#f0fdfa;border:2px solid #14b8a6;display:inline-block;flex-shrink:0;box-sizing:border-box;"></span>
+                    Linked from here
+                </span>`
+            );
+        }
+        legendItems.push(
+            `<span class="clusterlane-graph-legend-item">
+                <span style="width:9px;height:9px;border-radius:50%;background:#0f172a;display:inline-block;flex-shrink:0;"></span>
+                This article
+            </span>`
+        );
+        graphLegend.className = 'clusterlane-graph-legend';
+        graphLegend.innerHTML = legendItems.join( '' );
+    }
+
+    function truncate( str, max ) {
+        return str.length > max ? str.slice( 0, max - 1 ) + '…' : str;
+    }
+
+    /* ------------------------------------------------------------------
+       AI Article Analysis
+    ------------------------------------------------------------------ */
+
+    async function runAiAnalysis( force = false ) {
+        if ( ! currentPostId ) return;
+
+        // Free edition: example of Pro's article analysis (demo site) — no AI call.
+        if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+            const ex = clusterlaneProExample( 'article_analysis' );
+            if ( ex ) {
+                renderAiAnalysis( ex );
+                aiAnalysisStatus.innerHTML = clusterlaneProBanner( 'AI Article Analysis' );
+                if ( aiAnalysisRefresh ) aiAnalysisRefresh.style.display = 'none';
+                clusterlaneFreezeExample( aiAnalysisResults );
+            }
+            return;
+        }
+        // Live AI (Pro): clusterlane-pro.js
+        return window.clusterlanePro.articleAiRun( { force, aiAnalysisBtn, aiAnalysisRefresh, aiAnalysisResults, aiAnalysisStatus, apiFetch, esc, inspector, renderAiAnalysis, get_currentPostId: () => currentPostId } );
+    }
+
+    if ( aiAnalysisBtn ) {
+        aiAnalysisBtn.onclick = () => runAiAnalysis( false );
+    }
+    if ( aiAnalysisRefresh ) {
+        aiAnalysisRefresh.onclick = () => runAiAnalysis( true );
+    }
+
+    function renderAiAnalysis( data ) {
+        const cached = data.cached ? ' <span class="clusterlane-ai-cache-note">(cached · ' + esc( timeAgo( data.generated_at ) ) + ( data.stale ? ' · the article has changed since — Regenerate for a fresh analysis' : '' ) + ')</span>' : '';
+        aiAnalysisStatus.innerHTML = '<span style="color:#10b981;font-weight:600;">✓ Analysis complete</span>' + cached;
+        if ( aiAnalysisRefresh ) aiAnalysisRefresh.style.display = '';
+
+        let html = '';
+
+        // --- Status summary ---
+        html += `<div class="clusterlane-ai-analysis-section">
+            <h4 class="clusterlane-ai-analysis-section-title">${ clusterlaneGlyph( 'clipboard' ) }Status summary</h4>
+            <p class="clusterlane-ai-analysis-text">${ esc( data.status_summary || '' ) }</p>
+        </div>`;
+
+        // --- Content improvements ---
+        if ( Array.isArray( data.content_improvements ) && data.content_improvements.length ) {
+            html += `<div class="clusterlane-ai-analysis-section">
+                <h4 class="clusterlane-ai-analysis-section-title">${ clusterlaneGlyph( 'pencil' ) }Content improvements</h4>
+                <div class="clusterlane-ai-analysis-list">`;
+            data.content_improvements.forEach( item => {
+                html += `<div class="clusterlane-ai-analysis-item">
+                    <span class="clusterlane-ai-analysis-item-label">${ esc( item.area ) }</span>
+                    <span class="clusterlane-ai-analysis-item-text">${ esc( item.suggestion ) }</span>
+                </div>`;
+            } );
+            html += `</div></div>`;
+        }
+
+        // --- Sister articles ---
+        if ( Array.isArray( data.sister_articles ) && data.sister_articles.length ) {
+            html += `<div class="clusterlane-ai-analysis-section">
+                <h4 class="clusterlane-ai-analysis-section-title">${ clusterlaneGlyph( 'leaf' ) }Possible sister articles</h4>
+                <p style="margin:0 0 10px;font-size:12px;color:#64748b;">Topics that could strengthen this cluster — create them and link back.</p>
+                <div class="clusterlane-ai-analysis-list">`;
+            data.sister_articles.forEach( item => {
+                html += `<div class="clusterlane-ai-analysis-item clusterlane-ai-analysis-item--sister">
+                    <span class="clusterlane-ai-analysis-item-label">"${ esc( item.title ) }"</span>
+                    <span class="clusterlane-ai-analysis-item-text">${ esc( item.rationale ) }</span>
+                </div>`;
+            } );
+            html += `</div></div>`;
+        }
+
+        // --- Cluster & link actions ---
+        if ( Array.isArray( data.cluster_link_actions ) && data.cluster_link_actions.length ) {
+            html += `<div class="clusterlane-ai-analysis-section">
+                <h4 class="clusterlane-ai-analysis-section-title">${ clusterlaneGlyph( 'link' ) }Cluster & link actions</h4>
+                <div class="clusterlane-ai-analysis-list">`;
+            data.cluster_link_actions.forEach( item => {
+                html += `<div class="clusterlane-ai-analysis-item">
+                    <span class="clusterlane-ai-analysis-item-label">${ esc( item.action ) }</span>
+                    <span class="clusterlane-ai-analysis-item-text">${ esc( item.rationale ) }</span>
+                </div>`;
+            } );
+            html += `</div></div>`;
+        }
+
+        aiAnalysisResults.innerHTML = html;
+    }
+
+    /* ------------------------------------------------------------------
+       Utilities
+    ------------------------------------------------------------------ */
+    function esc( str ) {
+        return String( str )
+            .replace( /&/g, '&amp;' )
+            .replace( /</g, '&lt;' )
+            .replace( />/g, '&gt;' )
+            .replace( /"/g, '&quot;' );
+    }
+
+    function timeAgo( utcStr ) {
+        if ( ! utcStr ) return '';
+        const d    = new Date( utcStr.replace( ' ', 'T' ) + 'Z' );
+        const secs = Math.round( ( Date.now() - d.getTime() ) / 1000 );
+        if ( secs < 60 )    return 'just now';
+        if ( secs < 3600 )  return Math.floor( secs / 60 )   + ' min ago';
+        if ( secs < 86400 ) return Math.floor( secs / 3600 ) + ' h ago';
+        const days = Math.floor( secs / 86400 );
+        return days === 1 ? 'yesterday' : days + ' days ago';
+    }
+
+    function truncTitle( str ) {
+        if ( ! str ) return '';
+        const decoded = str.replace( /&amp;/g, '&' ).replace( /&lt;/g, '<' )
+                           .replace( /&gt;/g, '>' ).replace( /&quot;/g, '"' )
+                           .replace( /&#039;/g, "'" ).replace( /&apos;/g, "'" );
+        const mode = ( clusterlaneData.titleTrunc || 'none' );
+        if ( mode === 'none'  ) return decoded;
+        // A hyphen only counts with spaces around it ("Open-Air" stays whole);
+        // an empty result keeps the full title. Same rules as the Settings preview.
+        let cut = decoded;
+        if ( mode === 'colon' ) cut = decoded.replace( /\s*:.*$/, '' ).trim();
+        if ( mode === 'dash'  ) cut = decoded.replace( /(\s*[\u2013\u2014]|\s+-\s).*$/, '' ).trim();
+        if ( mode === 'pipe'  ) cut = decoded.replace( /\s*\|.*$/, '' ).trim();
+        if ( mode !== 'custom' ) return cut || decoded;
+        if ( mode === 'custom' && clusterlaneData.titleTruncCustom ) {
+            try {
+                const m = clusterlaneData.titleTruncCustom.match( /^\/(.+)\/([gimsuy]*)$/ );
+                if ( m ) return decoded.replace( new RegExp( m[1], m[2] ), '' ).trim() || decoded;
+            } catch(e) {}
+        }
+        return decoded;
+    }
+
+    // Show ← back link if navigated from Cluster Inspector.
+    ( function() {
+        const params = new URLSearchParams( document.location.search );
+        const ck     = params.get( 'from_cluster' );
+        const cl     = params.get( 'from_cluster_label' );
+        if ( ! ck || ! cl ) return;
+        const clusterInspectorUrl = clusterlaneData.restUrl
+            .replace( /\/wp-json\/cci\/v1\/?$/, '' )
+            .replace( /\/index\.php$/, '' )
+            + '/wp-admin/admin.php?page=clusterlane-cluster-inspector';
+        const wrap = document.querySelector( '.clusterlane-wrap' );
+        if ( ! wrap ) return;
+        const backBar = document.createElement( 'div' );
+        backBar.className = 'clusterlane-back-bar';
+        backBar.innerHTML = `← <a href="${ esc( clusterInspectorUrl ) }" class="clusterlane-back-bar-link">${ esc( cl ) }</a>`;
+        wrap.insertBefore( backBar, wrap.firstChild );
+    } )();
+
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/cluster-admin.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/cluster-admin.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/cluster-admin.js	(revision 3751027)
@@ -0,0 +1,280 @@
+/* Clusterlane — Cluster Admin
+ *
+ * Automatic clusters: a save bar appears only when the selection changed.
+ * Custom clusters: one inline editor for "new" and "edit" (name, articles as
+ * removable chips, add via search). Edits save instantly; the table row
+ * updates with the fresh health/orphan numbers.
+ * window.clusterlaneCustomEditor.openNew(label, posts) lets "Get ideas with AI" open
+ * the editor pre-filled so a suggestion can be adjusted before it is created.
+ */
+(function () {
+    'use strict';
+    if (typeof clusterlaneData === 'undefined') return;
+    function $(s, r) { return (r || document).querySelector(s); }
+    function $$(s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); }
+    function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
+    function g(n) { return typeof clusterlaneGlyph === 'function' ? clusterlaneGlyph(n) : ''; }
+    function band(h) { return h >= 70 ? 'good' : h >= 50 ? 'fair' : 'weak'; }
+    function api(path, method, body) {
+        return fetch(clusterlaneData.restUrl + path, {
+            method: method || 'GET', credentials: 'same-origin',
+            headers: { 'X-WP-Nonce': clusterlaneData.nonce, 'Content-Type': 'application/json' },
+            body: body ? JSON.stringify(body) : undefined,
+        }).then(function (r) {
+            return r.json().catch(function () { return {}; }).then(function (d) {
+                if (!r.ok) throw new Error((d && d.error) || ('HTTP ' + r.status));
+                return d;
+            });
+        });
+    }
+
+    /* ------------------------------------------------------------------
+       Automatic clusters — save bar
+    ------------------------------------------------------------------ */
+    var form = $('#clusterlane-ca-form');
+    if (form) {
+        var boxes = $$('input[type="checkbox"]', form);
+        var initial = boxes.map(function (b) { return b.checked; });
+        var bar = $('#clusterlane-ca-savebar'), count = $('#clusterlane-ca-changes');
+        var minField = form.querySelector('input[name="cluster_min_count"]'), minInitial = minField ? minField.value : '';
+        // Each row's status says what saving will do ("Will be added" / "Will be removed").
+        boxes.forEach(function (b) {
+            var st = b.closest('.clusterlane-ca-term') && b.closest('.clusterlane-ca-term').querySelector('.clusterlane-ca-status');
+            if (st) st.dataset.orig = st.innerHTML;
+        });
+        function refresh() {
+            boxes.forEach(function (b, i) {
+                var st = b.closest('.clusterlane-ca-term') && b.closest('.clusterlane-ca-term').querySelector('.clusterlane-ca-status');
+                if (!st) return;
+                st.classList.toggle('clusterlane-ca-status--pending', b.checked !== initial[i]);
+                st.innerHTML = b.checked === initial[i] ? st.dataset.orig : (b.checked ? 'Will be added' : 'Will be removed');
+            });
+            var n = boxes.filter(function (b, i) { return b.checked !== initial[i]; }).length
+                  + (minField && minField.value !== minInitial ? 1 : 0);
+            bar.hidden = n === 0;
+            count.textContent = n + (n === 1 ? ' unsaved change.' : ' unsaved changes.');
+        }
+        form.addEventListener('change', refresh);
+        $$('[data-bulk], [data-bulk-name]', form).forEach(function (btn) {
+            btn.addEventListener('click', function () {
+                var name = btn.dataset.bulkName || { cats: 'cluster_categories[]', tags: 'cluster_tags[]', pcats: 'cluster_product_cats[]' }[ btn.dataset.bulk ] || 'cluster_tags[]';
+                $$('input[name="' + name + '"]', form).forEach(function (b) { b.checked = btn.dataset.on === '1'; });
+                refresh();
+            });
+        });
+        $('#clusterlane-ca-revert').addEventListener('click', function () { boxes.forEach(function (b, i) { b.checked = initial[i]; }); if (minField) minField.value = minInitial; refresh(); });
+        form.addEventListener('input', refresh);
+        form.addEventListener('submit', function () {
+            var s = form.querySelector('button[type="submit"]');
+            if (s) { s.textContent = 'Saving & rebuilding…'; setTimeout(function () { s.disabled = true; }, 0); }
+        });
+        window.addEventListener('beforeunload', function (e) {
+            if (!bar.hidden && !form.dataset.submitting) { e.preventDefault(); e.returnValue = ''; }
+        });
+        form.addEventListener('submit', function () { form.dataset.submitting = '1'; });
+    }
+
+    /* ------------------------------------------------------------------
+       Custom clusters — inline editor
+    ------------------------------------------------------------------ */
+    var custom = $('#clusterlane-ca-custom');
+    if (!custom) return;
+    var rows = $('#clusterlane-ca-rows'), wrap = $('#clusterlane-ca-table-wrap'), empty = $('#clusterlane-ca-empty'), newSlot = $('#clusterlane-ca-new-slot');
+    var open = null;   // the one editor that is open { el, key, row }
+
+    function closeEditor() {
+        if (!open) return;
+        if (open.holder) open.holder.remove(); else open.el.remove();
+        if (open.row) open.row.classList.remove('is-editing');
+        open = null;
+    }
+
+    function editorHtml(isNew, label) {
+        return '<div class="clusterlane-ca-editor">'
+            + '<label class="clusterlane-ca-e-label">Name'
+            + '<input type="text" class="clusterlane-ca-e-name" value="' + esc(label || '') + '" placeholder="e.g. Christmas in Stockholm" autocomplete="off"></label>'
+            + '<div class="clusterlane-ca-e-label">Articles <span class="clusterlane-tm-note clusterlane-ca-e-count"></span></div>'
+            + '<div class="clusterlane-ca-e-chips"></div>'
+            + '<div class="clusterlane-browse__search clusterlane-ca-e-search">' + g('search') + '<input type="search" class="clusterlane-ca-e-q" placeholder="Add an article — search by title…" autocomplete="off"></div>'
+            + '<div class="clusterlane-ls2-results clusterlane-ca-e-results" hidden></div>'
+            + '<div class="clusterlane-ca-e-foot">'
+            + (isNew
+                ? '<span class="clusterlane-tm-note clusterlane-ca-e-msg">Add at least 2 articles.</span><span><button type="button" class="clusterlane-tm-btn clusterlane-ca-e-cancel">Cancel</button> <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply clusterlane-ca-e-create" disabled>Create cluster</button></span>'
+                : '<span class="clusterlane-tm-note clusterlane-ca-e-msg">Changes save as you make them.</span><button type="button" class="clusterlane-tm-btn clusterlane-ca-e-cancel">Done</button>')
+            + '</div></div>';
+    }
+
+    function mountEditor(opts) {
+        closeEditor();
+        var el, holder = null, row = opts.row || null;
+        if (row) {
+            holder = document.createElement('tr');
+            holder.className = 'clusterlane-ca-editor-row';
+            holder.innerHTML = '<td colspan="5">' + editorHtml(false, opts.label) + '</td>';
+            row.parentNode.insertBefore(holder, row.nextSibling);
+            row.classList.add('is-editing');
+            el = holder.querySelector('.clusterlane-ca-editor');
+        } else {
+            newSlot.innerHTML = editorHtml(true, opts.label);
+            el = newSlot.firstChild;
+        }
+        open = { el: el, holder: holder, row: row, key: opts.key || null, posts: (opts.posts || []).slice() };
+        wireEditor(open);
+        renderChips(open);
+        (opts.key ? el.querySelector('.clusterlane-ca-e-q') : el.querySelector('.clusterlane-ca-e-name')).focus();
+        el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+        return open;
+    }
+
+    function renderChips(ed) {
+        var box = ed.el.querySelector('.clusterlane-ca-e-chips');
+        box.innerHTML = ed.posts.length
+            ? ed.posts.map(function (p) {
+                return '<span class="clusterlane-ca-chip" data-id="' + p.id + '">' + esc(p.title)
+                    + '<button type="button" aria-label="Remove ' + esc(p.title) + '">×</button></span>';
+            }).join('')
+            : '<span class="clusterlane-tm-note">No articles yet — search below to add some.</span>';
+        ed.el.querySelector('.clusterlane-ca-e-count').textContent = ed.posts.length ? '(' + ed.posts.length + ')' : '';
+        var create = ed.el.querySelector('.clusterlane-ca-e-create');
+        if (create) {
+            var ok = ed.el.querySelector('.clusterlane-ca-e-name').value.trim() && ed.posts.length >= 2;
+            create.disabled = !ok;
+            ed.el.querySelector('.clusterlane-ca-e-msg').textContent = ed.posts.length >= 2 ? (ok ? 'Ready to create.' : 'Give it a name.') : 'Add at least 2 articles.';
+        }
+    }
+
+    function flash(ed, msg, isErr) {
+        var m = ed.el.querySelector('.clusterlane-ca-e-msg');
+        m.textContent = msg; m.classList.toggle('clusterlane-ca-e-err', !!isErr);
+    }
+
+    function updateRow(key, sum) {
+        var row = rows.querySelector('tr.clusterlane-ca-row[data-key="' + key + '"]');
+        if (!row || !sum) return;
+        if (sum.label) { row.dataset.label = sum.label; row.querySelector('.clusterlane-ca-name').textContent = sum.label; }
+        row.querySelector('.clusterlane-ca-members').textContent = sum.members;
+        var o = row.querySelector('.clusterlane-ca-orphans');
+        o.textContent = sum.orphans; o.className = 'num clusterlane-ca-orphans ' + (sum.orphans ? 'clusterlane-browse__zero' : 'clusterlane-browse__muted');
+        row.querySelector('.clusterlane-ca-health').innerHTML = '<span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--' + band(sum.health) + '"><span class="clusterlane-ci-health-node" aria-hidden="true"></span>' + sum.health + '%</span>';
+    }
+
+    function rowHtml(key, sum) {
+        return '<tr class="clusterlane-ca-row" data-key="' + esc(key) + '" data-label="' + esc(sum.label) + '">'
+            + '<td class="clusterlane-browse__col-title"><strong class="clusterlane-ca-name">' + esc(sum.label) + '</strong></td>'
+            + '<td class="num clusterlane-ca-members"></td><td class="num clusterlane-ca-orphans"></td><td class="num clusterlane-ca-health"></td>'
+            + '<td class="clusterlane-ca-actions"><button type="button" class="clusterlane-tm-btn clusterlane-ca-edit">Edit</button> '
+            + '<a class="clusterlane-tm-btn" href="' + esc((clusterlaneData.adminUrl || '/wp-admin/') + 'admin.php?page=clusterlane-cluster-inspector&open=' + encodeURIComponent(key)) + '">Inspect</a> '
+            + '<button type="button" class="clusterlane-tm-btn clusterlane-ca-delete" aria-label="Delete ' + esc(sum.label) + '">' + g('trash') + '</button></td></tr>';
+    }
+
+    function wireEditor(ed) {
+        var q = ed.el.querySelector('.clusterlane-ca-e-q'), res = ed.el.querySelector('.clusterlane-ca-e-results'), t;
+        var name = ed.el.querySelector('.clusterlane-ca-e-name');
+
+        q.addEventListener('input', function () {
+            clearTimeout(t);
+            var v = q.value.trim();
+            if (v.length < 2) { res.hidden = true; return; }
+            t = setTimeout(function () {
+                api('posts/search', 'POST', { q: v, limit: 10 }).then(function (d) {
+                    var have = {}; ed.posts.forEach(function (p) { have[p.id] = 1; });
+                    var list = (d.posts || []).filter(function (p) { return !have[p.id]; });
+                    res.hidden = false;
+                    res.innerHTML = list.length ? list.map(function (p) {
+                        return '<button type="button" data-id="' + p.id + '" data-title="' + esc(p.title) + '">' + esc(p.title) + '</button>';
+                    }).join('') : '<p class="clusterlane-tm-note">No more articles match.</p>';
+                });
+            }, 250);
+        });
+
+        res.addEventListener('click', function (e) {
+            var b = e.target.closest('button[data-id]'); if (!b) return;
+            var p = { id: parseInt(b.dataset.id, 10), title: b.dataset.title };
+            res.hidden = true; q.value = ''; q.focus();
+            if (!ed.key) { ed.posts.push(p); renderChips(ed); return; }
+            ed.posts.push(p); renderChips(ed); flash(ed, 'Adding…');
+            api('clusters/custom/' + ed.key, 'POST', { add_ids: [p.id] })
+                .then(function (d) { updateRow(ed.key, d.summary); flash(ed, 'Saved — ' + p.title + ' added.'); })
+                .catch(function (err) { ed.posts = ed.posts.filter(function (x) { return x.id !== p.id; }); renderChips(ed); flash(ed, err.message, true); });
+        });
+
+        ed.el.querySelector('.clusterlane-ca-e-chips').addEventListener('click', function (e) {
+            var x = e.target.closest('.clusterlane-ca-chip button'); if (!x) return;
+            var id = parseInt(x.parentNode.dataset.id, 10);
+            var removed = ed.posts.filter(function (p) { return p.id === id; })[0];
+            ed.posts = ed.posts.filter(function (p) { return p.id !== id; }); renderChips(ed);
+            if (!ed.key) return;
+            flash(ed, 'Removing…');
+            api('clusters/custom/' + ed.key, 'POST', { remove_ids: [id] })
+                .then(function (d) { updateRow(ed.key, d.summary); flash(ed, 'Saved — ' + (removed ? removed.title : 'article') + ' removed.'); })
+                .catch(function (err) { if (removed) ed.posts.push(removed); renderChips(ed); flash(ed, err.message, true); });
+        });
+
+        name.addEventListener('input', function () { if (!ed.key) renderChips(ed); });
+        function saveName() {
+            if (!ed.key) return;
+            var v = name.value.trim(), row = ed.row;
+            if (!v || v === row.dataset.label) { name.value = row.dataset.label; return; }
+            flash(ed, 'Renaming…');
+            api('clusters/custom/' + ed.key, 'POST', { label: v })
+                .then(function (d) { updateRow(ed.key, d.summary || { label: v }); flash(ed, 'Saved — renamed.'); })
+                .catch(function (err) { flash(ed, err.message, true); });
+        }
+        name.addEventListener('change', saveName);
+        name.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); name.blur(); } });
+
+        ed.el.querySelector('.clusterlane-ca-e-cancel').addEventListener('click', closeEditor);
+
+        var create = ed.el.querySelector('.clusterlane-ca-e-create');
+        if (create) create.addEventListener('click', function () {
+            create.disabled = true; create.textContent = 'Creating…';
+            api('clusters/custom', 'POST', { label: name.value.trim(), post_ids: ed.posts.map(function (p) { return p.id; }) })
+                .then(function (d) {
+                    var sum = d.summary || { label: name.value.trim(), members: ed.posts.length, orphans: 0, health: 0 };
+                    rows.insertAdjacentHTML('afterbegin', rowHtml(d.cluster_key, sum));
+                    updateRow(d.cluster_key, sum);
+                    wrap.hidden = false; empty.hidden = true;
+                    var row = rows.firstElementChild; row.classList.add('clusterlane-ca-row--new');
+                    setTimeout(function () { row.classList.remove('clusterlane-ca-row--new'); }, 2500);
+                    if (typeof ed.onCreated === 'function') ed.onCreated(d);
+                    closeEditor();
+                    row.scrollIntoView({ block: 'center', behavior: 'smooth' });
+                })
+                .catch(function (err) { create.disabled = false; create.textContent = 'Create cluster'; flash(ed, err.message, true); });
+        });
+    }
+
+    $('#clusterlane-ca-new').addEventListener('click', function () { mountEditor({ label: '', posts: [] }); });
+
+    rows.addEventListener('click', function (e) {
+        var row = e.target.closest('tr.clusterlane-ca-row'); if (!row) return;
+        if (e.target.closest('.clusterlane-ca-edit')) {
+            if (open && open.row === row) { closeEditor(); return; }
+            var ed = mountEditor({ row: row, key: row.dataset.key, label: row.dataset.label, posts: [] });
+            ed.el.querySelector('.clusterlane-ca-e-chips').innerHTML = '<span class="clusterlane-tm-note">Loading articles…</span>';
+            api('clusters/members/' + row.dataset.key).then(function (d) {
+                ed.posts = (d.members || []).map(function (m) { return { id: m.id, title: m.title }; });
+                renderChips(ed);
+            }).catch(function (err) { flash(ed, err.message, true); });
+            return;
+        }
+        if (e.target.closest('.clusterlane-ca-delete')) {
+            if (!confirm('Delete the custom cluster “' + row.dataset.label + '”? The articles themselves are not affected.')) return;
+            if (open && open.row === row) closeEditor();
+            api('clusters/custom/' + row.dataset.key, 'DELETE').then(function () {
+                row.remove();
+                if (!rows.querySelector('tr.clusterlane-ca-row')) { wrap.hidden = true; empty.hidden = false; }
+            }).catch(function (err) { alert(err.message); });
+        }
+    });
+
+    // For "Get ideas with AI": open the editor pre-filled, so a suggestion can be adjusted first.
+    window.clusterlaneCustomEditor = {
+        openNew: function (label, posts, onCreated) {
+            var ed = mountEditor({ label: label, posts: posts || [] });
+            ed.onCreated = onCreated;
+            return ed;
+        },
+        existingLabels: function () { return $$('tr.clusterlane-ca-row', rows).map(function (r) { return r.dataset.label; }); },
+    };
+})();
Index: /clusterlane/tags/1.3.2/admin/js/cluster-inspector.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/cluster-inspector.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/cluster-inspector.js	(revision 3751027)
@@ -0,0 +1,2092 @@
+/* global clusterlaneData, vis */
+( function() {
+    'use strict';
+    // "?" tooltip markup (admin/js/clusterlane-tips.js); '' if unavailable.
+    const tip = ( key ) => ( typeof window.clusterlaneTip === 'function' ? window.clusterlaneTip( key ) : '' );
+
+    const clusterlaneData = window.clusterlaneData;
+    if ( ! clusterlaneData ) return;
+
+    const REST  = clusterlaneData.restUrl;
+    const NONCE = clusterlaneData.nonce;
+
+    // DOM refs
+    const statClusters = document.getElementById( 'clusterlane-ci-stat-clusters' );
+    const statPosts    = document.getElementById( 'clusterlane-ci-stat-posts' );
+    const statHealth   = document.getElementById( 'clusterlane-ci-stat-health' );
+    const statPillars  = document.getElementById( 'clusterlane-ci-stat-pillars' );
+    const statOrphans  = document.getElementById( 'clusterlane-ci-stat-orphans' );
+
+    const flowGraph   = document.getElementById( 'clusterlane-ci-flow-graph' );
+    const flowOverlay = document.getElementById( 'clusterlane-ci-flow-overlay' );
+    const flowStatus  = document.getElementById( 'clusterlane-ci-flow-status' );
+    const flowLegend  = document.getElementById( 'clusterlane-ci-flow-legend' );
+    const mapWrap     = document.getElementById( 'clusterlane-ci-map-wrap' );
+    const mapCap      = document.getElementById( 'clusterlane-ci-map-cap' );
+    const tableWrap   = document.getElementById( 'clusterlane-ci-table-wrap' );
+    const viewSeg     = document.getElementById( 'clusterlane-ci-view' );
+    const focusWrap   = document.getElementById( 'clusterlane-ci-focus' );
+    const focusGrid   = document.getElementById( 'clusterlane-ci-focus-grid' );
+
+    const clusterCount = document.getElementById( 'clusterlane-ci-cluster-count' );
+    const clusterList  = document.getElementById( 'clusterlane-ci-cluster-list' );
+    const searchInput  = document.getElementById( 'clusterlane-ci-search' );
+    const typeFilters  = document.querySelectorAll( '.clusterlane-type-filter' );
+
+    const detailPanel  = document.getElementById( 'clusterlane-ci-detail-panel' );
+    const detailHeader = document.getElementById( 'clusterlane-ci-detail-header' );
+    const detailLabel  = document.getElementById( 'clusterlane-ci-detail-label' );
+    const detailStats  = document.getElementById( 'clusterlane-ci-detail-stats' );
+    const detailDist   = document.getElementById( 'clusterlane-ci-detail-distbar' );
+    const detailMembers= document.getElementById( 'clusterlane-ci-detail-members' );
+    const detailClose  = document.getElementById( 'clusterlane-ci-detail-close' );
+    const aiBtn        = document.getElementById( 'clusterlane-ci-ai-btn' );
+    const aiResult     = document.getElementById( 'clusterlane-ci-ai-result' );
+    const graphWrap    = document.getElementById( 'clusterlane-ci-detail-graph-wrap' );
+    const graphEl      = document.getElementById( 'clusterlane-ci-detail-graph' );
+    const graphOverlay = document.getElementById( 'clusterlane-ci-graph-overlay' );
+
+    /* ── Click-to-zoom ────────────────────────────────────────────────
+       vis-network grabs the mouse wheel as soon as zoomView is on, which
+       traps page scrolling on any inline graph. Start with zoom OFF; a
+       mousedown inside the graph enables it, leaving (or clicking
+       elsewhere) disables it. Drag-to-pan works regardless. */
+    function clusterlaneClickToZoom( network, container, hintText ) {
+        if ( ! network || ! container ) return;
+        if ( ! container.querySelector( '.clusterlane-graph-zoom-hint' ) ) {
+            const hint = document.createElement( 'div' );
+            hint.className = 'clusterlane-graph-zoom-hint';
+            hint.textContent = hintText || 'Click to enable zoom · drag to pan';
+            container.appendChild( hint );
+        }
+        // Always act on the CURRENT network (a new one is built per cluster/article).
+        container.__clusterlaneNet = network;
+        container.__clusterlaneZoomOn = null;
+        const setZoom = on => {
+            if ( container.__clusterlaneZoomOn === on ) return;   // no change → no vis redraw
+            container.__clusterlaneZoomOn = on;
+            try { container.__clusterlaneNet.setOptions( { interaction: { zoomView: on } } ); } catch ( e ) {}
+            container.classList.toggle( 'is-zoom-active', on );
+        };
+        setZoom( false );
+        if ( container.__clusterlaneZoomBound ) return;
+        container.__clusterlaneZoomBound = true;
+        container.addEventListener( 'mousedown', () => setZoom( true ) );
+        container.addEventListener( 'mouseleave', () => setZoom( false ) );
+        // Clicking elsewhere turns zoom off AFTER that click has completed.
+        // (Doing it on mousedown re-rendered the graph in the middle of the
+        // click; content below the graph could shift under the pointer, so the
+        // click never reached e.g. the cluster rows below an open panel.)
+        document.addEventListener( 'click', e => { if ( ! container.contains( e.target ) ) setTimeout( () => setZoom( false ), 0 ); } );
+    }
+    const graphStatus  = document.getElementById( 'clusterlane-ci-graph-status' );
+    const graphLegend  = document.getElementById( 'clusterlane-ci-graph-legend' );
+    const graphExpandBtn = document.getElementById( 'clusterlane-ci-graph-expand-btn' );
+
+    const graphModal       = document.getElementById( 'clusterlane-ci-graph-modal' );
+    const graphModalCanvas = document.getElementById( 'clusterlane-ci-graph-modal-canvas' );
+    const graphModalRight  = document.getElementById( 'clusterlane-ci-graph-modal-right' );
+    const graphModalTitle  = document.getElementById( 'clusterlane-ci-graph-modal-title' );
+    const graphModalLegend = document.getElementById( 'clusterlane-ci-graph-modal-legend' );
+    const graphModalClose  = document.getElementById( 'clusterlane-ci-graph-modal-close' );
+    const graphInlineInfobar = document.getElementById( 'clusterlane-ci-graph-infobar' );
+    const graphModalInfobar  = document.getElementById( 'clusterlane-ci-graph-modal-infobar' );
+
+    let detailVisNetwork = null;
+    let lastGraphData    = null; // cache so modal doesn't need a second fetch
+    let lastGraphPositions = null; // final node positions from inline graph → reused in modal
+
+    let allClusters   = [];
+    let edgesTotal    = 0;
+    // Sections (0.98.160): the site's main menu / top categories, from ClusterSections.
+    let sections = [], sectionMap = {}, sectionChoices = [];
+    const GROUP_KEY = 'clusterlane-ci-group', FOLD_KEY = 'clusterlane-ci-folded';
+    const canManageList = parseInt( clusterlaneData.canManage, 10 ) === 1;
+    let folded = new Set();
+    try { folded = new Set( JSON.parse( localStorage.getItem( FOLD_KEY ) || '[]' ) ); } catch ( e ) {}
+    let allGraphNodes = [];
+    let allGraphEdges = [];
+    let activeFilters = new Set();
+    let visNetwork    = null;
+    let currentKey   = null;
+    let currentLabel = '';
+    // List | Map — one card, same filters. The map follows the list's
+    // search/health/type filters and sort order (capped at MAP_MAX nodes).
+    let view          = 'list';
+    const MAP_MAX     = 40;
+    const VIEW_KEY    = 'clusterlane-ci-view';
+    let mapSig        = '';   // node set last drawn — skip relayout if unchanged
+    let mapTimer      = null;
+
+    /* ------------------------------------------------------------------
+       Boot
+    ------------------------------------------------------------------ */
+
+    async function boot() {
+        try {
+            const r = await fetch( REST + 'cluster-inspector/overview', {
+                headers: { 'X-WP-Nonce': NONCE },
+            } );
+            const data = await r.json();
+            renderHeroStats( data.stats );
+            allClusters  = data.clusters || [];
+            setSections( data );
+            allGraphNodes = data.nodes   || [];
+            allGraphEdges = data.edges   || [];
+            edgesTotal    = +data.edges_total || allGraphEdges.length; // 0.98.171: the map gets the strongest connections only
+            renderFocus();
+            let saved = 'list';
+            try { saved = localStorage.getItem( VIEW_KEY ) === 'map' ? 'map' : 'list'; } catch ( e ) {}
+            setView( saved, true ); // renders the list (and the map when chosen)
+
+            // Auto-open cluster from ?open= URL param (e.g. from Dashboard quick nav).
+            const qs      = new URLSearchParams( document.location.search );
+            const openKey = qs.get( 'open' );
+            if ( openKey ) {
+                await loadDetail( openKey );
+                // ?focus= from Site Intelligence Analysis: go straight to the right block.
+                const focus = qs.get( 'focus' );
+                if ( focus ) setTimeout( () => focusDetail( focus ), 350 );
+            }
+
+        } catch ( err ) {
+            if ( flowStatus ) flowStatus.textContent = 'Failed to load: ' + err.message;
+            console.error( 'Clusterlane Cluster Inspector:', err );
+        }
+    }
+
+    /* ------------------------------------------------------------------
+       Hero stats
+    ------------------------------------------------------------------ */
+
+    function renderHeroStats( stats ) {
+        if ( ! stats ) return;
+        const healthColor = stats.avg_health >= 70 ? '#4ade80' : stats.avg_health >= 50 ? '#fbbf24' : '#f87171';
+        if ( statClusters ) statClusters.textContent = stats.total_clusters ?? '—';
+        if ( statPosts )    statPosts.textContent    = stats.total_posts    ?? '—';
+        if ( statHealth ) {
+            if ( stats.avg_health >= 100 ) {
+                statHealth.textContent = '100%';
+                statHealth.title       = 'Perfect health across all clusters!';
+            } else {
+                statHealth.textContent = ( stats.avg_health ?? '—' ) + '%';
+            }
+            statHealth.style.color = healthColor;
+        }
+        if ( statPillars )  statPillars.textContent  = stats.pillar_count  ?? '—';
+        if ( statOrphans ) {
+            if ( stats.orphan_count === 0 ) {
+                statOrphans.textContent = '✓ 0';
+                statOrphans.style.color = '#4ade80';
+                statOrphans.title       = 'No orphans — great cluster hygiene!';
+            } else {
+                statOrphans.textContent = stats.orphan_count ?? '—';
+                statOrphans.style.color = '#f87171';
+                statOrphans.title       = '';
+            }
+        }
+    }
+
+    /* ------------------------------------------------------------------
+       Focus next — the three clusters where work pays off most.
+       Computed from the overview data (no AI, no extra request):
+       low health weighted by size, plus orphans. Healthy (≥70) and
+       one-article clusters are left out.
+    ------------------------------------------------------------------ */
+
+    const FOCUS_MAX = 3;
+    const plural = ( n, one, many ) => n + ' ' + ( n === 1 ? one : many );
+
+    function focusCandidates() {
+        const inflow = {};
+        allGraphEdges.forEach( e => { inflow[ e.to ] = ( inflow[ e.to ] || 0 ) + ( +e.value || 0 ); } );
+        const hasFlow = allGraphEdges.length > 0;
+        return allClusters
+            .filter( c => +c.health_score < 70 && +c.total_members >= 2 )
+            .map( c => {
+                const h = +c.health_score, m = +c.total_members, o = +c.orphans || 0, p = +c.pillars || 0;
+                const score = ( 70 - h ) * Math.log2( m + 1 ) + o * 4;
+                let why;
+                if ( o > 0 )                                   why = plural( o, 'article has', 'articles have' ) + ' no links pointing to ' + ( o === 1 ? 'it' : 'them' );
+                else if ( p === 0 && m >= 4 )                  why = 'No pillar article anchors the topic yet';
+                else if ( hasFlow && ! inflow[ c.cluster_key ] ) why = 'No links from other clusters';
+                else                                           why = 'Its articles link to each other too little';
+                return { c, h, m, score, why };
+            } )
+            .sort( ( a, b ) => b.score - a.score || a.h - b.h )
+            .slice( 0, FOCUS_MAX );
+    }
+
+    function renderFocus() {
+        if ( ! focusWrap || ! focusGrid ) return;
+        if ( ! allClusters.length ) { focusWrap.style.display = 'none'; return; }
+        focusWrap.style.display = '';
+        const picks = focusCandidates();
+        if ( ! picks.length ) {
+            focusGrid.innerHTML = `<p class="clusterlane-ci-focus-done">${ clusterlaneGlyph( 'check' ) }All clusters are at 70% health or more — nothing urgent.</p>`;
+            return;
+        }
+        focusGrid.innerHTML = picks.map( ( f, i ) => `
+            <button type="button" class="clusterlane-ci-focus-card clusterlane-ci-detail-health--${ bandOf( f.h ) }" data-key="${ esc( f.c.cluster_key ) }">
+                <span class="clusterlane-ci-focus-top">
+                    <span class="clusterlane-ci-focus-rank">${ i + 1 }</span>
+                    <span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--${ bandOf( f.h ) }"><span class="clusterlane-ci-health-node" aria-hidden="true"></span>${ f.h }%</span>
+                    <span class="clusterlane-ci-focus-size">${ plural( f.m, 'article', 'articles' ) }</span>
+                </span>
+                <strong class="clusterlane-ci-focus-name">${ esc( f.c.cluster_label ) }</strong>
+                <span class="clusterlane-ci-focus-why">${ esc( f.why ) }</span>
+                <span class="clusterlane-ci-focus-go">Improve ${ clusterlaneGlyph( 'arrow' ) }</span>
+            </button>` ).join( '' );
+    }
+
+    // Open the cluster; make sure its row is listed so the panel lands under it.
+    if ( focusGrid ) focusGrid.addEventListener( 'click', e => {
+        const card = e.target.closest( '.clusterlane-ci-focus-card' );
+        if ( ! card ) return;
+        openInPlace( card.dataset.key );
+    } );
+    // The quick view's "Open in Cluster Inspector" opens the cluster here, without a reload (0.98.200).
+    window.clusterlaneClusterQuickOpen = key => openInPlace( key );
+
+    function openInPlace( key ) {
+        if ( view === 'list' && ! filtered().some( c => c.cluster_key === key ) ) {
+            ciTable.band = '';
+            activeFilters.clear();
+            typeFilters.forEach( b => b.classList.remove( 'active' ) );
+            if ( searchInput ) searchInput.value = '';
+            renderClusterList();
+        }
+        document.querySelectorAll( '.clusterlane-ci-cluster-row' ).forEach( r =>
+            r.classList.toggle( 'clusterlane-ci-cluster-row--active', r.dataset.key === key )
+        );
+        if ( key === currentKey ) { scrollToOpened( placeDetail( key ) ); return; }
+        loadDetail( key );
+        if ( visNetwork ) { try { visNetwork.selectNodes( [ key ], true ); } catch ( err ) {} }
+    }
+
+    /* ------------------------------------------------------------------
+       Flow graph
+    ------------------------------------------------------------------ */
+
+    // The map shows the clusters currently listed (same filters, same order).
+    function mapData() {
+        const list  = filtered();
+        const keys  = list.slice( 0, MAP_MAX ).map( c => c.cluster_key );
+        const keep  = new Set( keys );
+        const nodes = allGraphNodes.filter( n => keep.has( n.id ) );
+        const edges = allGraphEdges.filter( e => keep.has( e.from ) && keep.has( e.to ) );
+        return { nodes, edges, total: list.length, sig: keys.slice().sort().join( '|' ) };
+    }
+
+    function refreshMap( force ) {
+        if ( view !== 'map' ) return;
+        const d = mapData();
+        if ( mapCap ) mapCap.textContent = ( d.total > MAP_MAX
+            ? 'Showing ' + MAP_MAX + ' of ' + d.total + ' (first in the current sort) — narrow with the filters above.'
+            : '' ) + ( edgesTotal > allGraphEdges.length
+            ? ( d.total > MAP_MAX ? ' ' : '' ) + 'Lines: the strongest ' + allGraphEdges.length.toLocaleString() + ' of ' + edgesTotal.toLocaleString() + ' connections between clusters.'
+            : '' );
+        if ( ! force && d.sig === mapSig && visNetwork ) return;
+        mapSig = d.sig;
+        buildFlowGraph( d );
+    }
+
+    // Typing in the search box re-renders the list per keystroke; the map waits.
+    function scheduleMap() {
+        if ( view !== 'map' ) return;
+        clearTimeout( mapTimer );
+        mapTimer = setTimeout( () => refreshMap( false ), 250 );
+    }
+
+    function setView( v, initial ) {
+        view = v === 'map' ? 'map' : 'list';
+        try { localStorage.setItem( VIEW_KEY, view ); } catch ( e ) {}
+        if ( viewSeg ) viewSeg.querySelectorAll( 'button[data-view]' ).forEach( b => {
+            const on = b.dataset.view === view;
+            b.classList.toggle( 'is-active', on );
+            b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
+        } );
+        if ( tableWrap ) tableWrap.style.display = view === 'list' ? '' : 'none';
+        if ( mapWrap )   mapWrap.style.display   = view === 'map'  ? '' : 'none';
+        const tb = document.getElementById( 'clusterlane-ci-toolbar' );
+        if ( tb ) tb.classList.toggle( 'is-map', view === 'map' ); // grouping is for the list
+        renderClusterList(); // also re-homes an open detail panel (under its row, or below the card)
+        if ( view === 'map' ) {
+            clearTimeout( mapTimer );
+            // Canvas must be visible before vis measures it.
+            requestAnimationFrame( () => refreshMap( true ) );
+        }
+        if ( ! initial && currentKey && detailPanel && detailPanel.style.display !== 'none' ) {
+            scrollToOpened( view === 'list' ? placeDetail( currentKey ) : null );
+        }
+    }
+
+    if ( viewSeg ) viewSeg.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-view]' );
+        if ( b && b.dataset.view !== view ) setView( b.dataset.view );
+    } );
+
+    function buildFlowGraph( { nodes, edges } ) {
+        if ( ! flowGraph ) return;
+
+        if ( ! nodes.length ) {
+            if ( flowOverlay ) flowOverlay.classList.remove( 'hidden' );
+            if ( flowStatus  ) flowStatus.textContent = allGraphNodes.length > 0
+                ? 'No clusters match these filters.'
+                : 'No cross-cluster link data yet — click Scan in the bar at the top first.';
+            if ( visNetwork ) { visNetwork.destroy(); visNetwork = null; }
+            return;
+        }
+        if ( flowOverlay ) flowOverlay.classList.remove( 'hidden' );
+        if ( flowStatus  ) flowStatus.textContent = 'Laying out graph…';
+
+        const ds_nodes = new vis.DataSet( nodes );
+        const ds_edges = new vis.DataSet( edges );
+
+        // Fewer nodes → less physics needed. Cap iterations to keep it snappy.
+        const iters = Math.min( 120, 40 + nodes.length * 2 );
+
+        const options = {
+            layout: { randomSeed: 7 }, // same picture on every load (0.98.182)
+            physics: {
+                solver: 'forceAtlas2Based',
+                forceAtlas2Based: {
+                    gravitationalConstant: -60,
+                    centralGravity: 0.02,
+                    springLength: 140,
+                    springConstant: 0.05,
+                    damping: 0.5,
+                },
+                stabilization: { enabled: true, iterations: iters, updateInterval: 25, fit: true },
+            },
+            interaction: { tooltipDelay: 100, hover: true, zoomView: false },
+            nodes: {
+                shape: 'dot',
+                borderWidth: 2,
+                shadow: { enabled: true, size: 6, x: 2, y: 2, color: 'rgba(0,0,0,.08)' },
+            },
+            edges: {
+                smooth: { type: 'curvedCW', roundness: 0.2 },
+            },
+        };
+
+        if ( visNetwork ) { visNetwork.destroy(); visNetwork = null; }
+        visNetwork = new vis.Network( flowGraph, { nodes: ds_nodes, edges: ds_edges }, options );
+        clusterlaneClickToZoom( visNetwork, flowGraph );
+
+        visNetwork.once( 'stabilizationIterationsDone', () => {
+            visNetwork.setOptions( { physics: { enabled: false } } );
+            visNetwork.fit( { animation: { duration: 300, easingFunction: 'easeInOutQuad' } } );
+            if ( flowOverlay ) flowOverlay.classList.add( 'hidden' );
+        } );
+
+        // Click node → load that cluster's detail.
+        visNetwork.on( 'click', params => {
+            if ( ! params.nodes.length ) return;
+            const key = params.nodes[0];
+            loadDetail( key );
+            // Highlight corresponding row in list.
+            document.querySelectorAll( '.clusterlane-ci-cluster-row' ).forEach( r => {
+                r.classList.toggle( 'clusterlane-ci-cluster-row--active', r.dataset.key === key );
+            } );
+        } );
+
+        visNetwork.on( 'hoverNode', () => { flowGraph.style.cursor = 'pointer'; } );
+        visNetwork.on( 'blurNode',  () => { flowGraph.style.cursor = 'default'; } );
+
+        // Legend.
+        if ( flowLegend ) {
+            flowLegend.innerHTML = [
+                { bg: '#dcfce7', border: '#16a34a', label: 'Good (70%+)' },
+                { bg: '#fef9c3', border: '#ca8a04', label: 'Fair (50–69%)' },
+                { bg: '#fee2e2', border: '#dc2626', label: 'Weak (under 50%)' },
+            ].map( item =>
+                `<span class="clusterlane-graph-legend-item">
+                    <span style="width:10px;height:10px;border-radius:50%;background:${item.bg};border:2px solid ${item.border};display:inline-block;flex-shrink:0;box-sizing:border-box;"></span>
+                    ${esc( item.label )}
+                </span>`
+            ).join( '' );
+        }
+    }
+
+    /* ------------------------------------------------------------------
+       Cluster list
+    ------------------------------------------------------------------ */
+
+    const CLASS_COLORS = {
+        pillar:  { bg: '#fdeadf', border: '#e76c2e', text: '#b4491a' },
+        support: { bg: '#fdf4d4', border: '#f5c842', text: '#86600a' },
+        light:   { bg: '#edf4e4', border: '#90b865', text: '#4a6b2a' },
+        orphan:  { bg: '#e4f0f8', border: '#5ba4cf', text: '#245f84' },
+    };
+
+    /* ── Inline detail: the open cluster's panel sits right under its row ── */
+    const detailHome = document.createComment( 'clusterlane-detail-home' );
+    if ( detailPanel && detailPanel.parentNode ) detailPanel.parentNode.insertBefore( detailHome, detailPanel );
+    let detailRow = null;
+
+    // Back to the panel's original spot (below the table).
+    function parkDetail() {
+        if ( detailPanel && detailHome.parentNode && detailHome.nextSibling !== detailPanel ) {
+            detailHome.parentNode.insertBefore( detailPanel, detailHome.nextSibling );
+        }
+        if ( detailRow ) { detailRow.remove(); detailRow = null; }
+    }
+
+    // Under the cluster's row — if the row is visible (table open, not filtered out).
+    function placeDetail( key ) {
+        const listCard = document.getElementById( 'clusterlane-ci-list-card' );
+        const row = key && clusterList
+            ? [ ...clusterList.querySelectorAll( 'tr.clusterlane-ci-cluster-row' ) ].find( r => r.dataset.key === key )
+            : null;
+        if ( ! row || view !== 'list' || ( listCard && ! listCard.open ) ) { parkDetail(); return null; }
+        if ( ! detailRow ) {
+            detailRow = document.createElement( 'tr' );
+            detailRow.className = 'clusterlane-ci-detail-row';
+            const td = document.createElement( 'td' );
+            td.colSpan = 6;
+            detailRow.appendChild( td );
+        }
+        detailRow.firstChild.appendChild( detailPanel );
+        row.insertAdjacentElement( 'afterend', detailRow );
+        // The graph canvas moved with the panel — let vis re-measure it.
+        requestAnimationFrame( () => { if ( detailVisNetwork ) { try { detailVisNetwork.redraw(); } catch ( e ) {} } } );
+        return row;
+    }
+
+    // Bring the opened row to the top, just under the sticky dock.
+    function scrollToOpened( row ) {
+        const target = row || detailPanel;
+        if ( ! target ) return;
+        const top = target.getBoundingClientRect().top + window.scrollY - 170;
+        window.scrollTo( { top: Math.max( 0, top ), behavior: 'smooth' } );
+    }
+
+    const listCardEl = document.getElementById( 'clusterlane-ci-list-card' );
+    if ( listCardEl ) listCardEl.addEventListener( 'toggle', () => {
+        if ( ! currentKey || ! detailPanel || detailPanel.style.display === 'none' ) return;
+        listCardEl.open ? placeDetail( currentKey ) : parkDetail();
+    } );
+
+    // Table state: "needs attention" first = weakest health, then most orphans.
+    const ciTable = { sort: 'health', dir: 'asc', band: '', group: 'section' };
+    try { if ( localStorage.getItem( GROUP_KEY ) === 'none' ) ciTable.group = 'none'; } catch ( e ) {}
+
+    function setSections( data ) {
+        sections       = data.sections || [];
+        sectionMap     = data.section_map || {};
+        sectionChoices = data.section_choices || [];
+        const seg = document.getElementById( 'clusterlane-ci-group' );
+        if ( seg ) seg.hidden = ! useful() && ! canManageList; // admins can always start their own groups
+        const segBtns = seg && seg.querySelector( '.clusterlane-seg' );
+        if ( segBtns ) segBtns.hidden = ! useful();
+    }
+    // Your own groups always count. Otherwise: one section, or almost one per cluster
+    // (flat categories, no menu) = nothing to group.
+    const useful    = () => sections.some( x => x.source === 'group' ) || ( sections.length > 1 && sections.length < Math.max( 3, allClusters.length * 0.75 ) );
+    const sectionOf = c => ( sectionMap[ c.cluster_key ] || {} ).section || 'other';
+    const grouped   = () => ciTable.group === 'section' && useful();
+    const bandOf  = h => h >= 70 ? 'good' : h >= 50 ? 'fair' : 'weak';
+
+    function baseFiltered() {
+        let list = [ ...allClusters ];
+        const q = searchInput ? searchInput.value.trim().toLowerCase() : '';
+        if ( q ) list = list.filter( c => c.cluster_label.toLowerCase().includes( q ) || ( c.archetype || '' ).toLowerCase().includes( q ) );
+        if ( activeFilters.size ) list = list.filter( c => activeFilters.has( c.cluster_type ) );
+        return list;
+    }
+
+    function filtered() {
+        let list = baseFiltered();
+        if ( ciTable.band ) list = list.filter( c => bandOf( +c.health_score ) === ciTable.band );
+        const dir = ciTable.dir === 'asc' ? 1 : -1;
+        const val = {
+            label:     c => c.cluster_label.toLowerCase(),
+            archetype: c => ( c.archetype || 'zzz' ).toLowerCase(),
+            members:   c => +c.total_members,
+            orphans:   c => +c.orphans || 0,
+            pillars:   c => +c.pillars || 0,
+            health:    c => +c.health_score,
+        }[ ciTable.sort ];
+        list.sort( ( a, b ) => {
+            const x = val( a ), y = val( b );
+            if ( x < y ) return -dir;
+            if ( x > y ) return dir;
+            // Ties: more orphans first, then name.
+            return ( ( +b.orphans || 0 ) - ( +a.orphans || 0 ) ) || a.cluster_label.localeCompare( b.cluster_label );
+        } );
+        return list;
+    }
+
+    function renderClusterList() {
+        // Band chips: counts within the current search/type filter.
+        const base = baseFiltered();
+        const counts = { weak: 0, fair: 0, good: 0 };
+        base.forEach( c => counts[ bandOf( +c.health_score ) ]++ );
+        document.querySelectorAll( '#clusterlane-ci-bands button[data-band]' ).forEach( b => {
+            const band = b.dataset.band, em = b.querySelector( 'em' );
+            if ( em ) em.textContent = counts[ band ] || 0;
+            b.classList.toggle( 'is-active', band === ciTable.band );
+            b.setAttribute( 'aria-pressed', band === ciTable.band ? 'true' : 'false' );
+        } );
+        document.querySelectorAll( '.clusterlane-ci-table th button[data-sort]' ).forEach( b => {
+            const on = b.dataset.sort === ciTable.sort;
+            b.classList.toggle( 'is-sorted', on );
+            b.dataset.dir = on ? ciTable.dir : '';
+            b.closest( 'th' ).setAttribute( 'aria-sort', on ? ( ciTable.dir === 'asc' ? 'ascending' : 'descending' ) : 'none' );
+        } );
+
+        const list = filtered();
+        // "44 clusters" — or "12 of 44 shown" while a search or filter narrows the list.
+        if ( clusterCount ) {
+            clusterCount.textContent = list.length === allClusters.length
+                ? allClusters.length + ( allClusters.length === 1 ? ' cluster' : ' clusters' )
+                : list.length + ' of ' + allClusters.length + ' shown';
+            clusterCount.title = list.length === allClusters.length ? '' : 'A search or filter is on — clear it to see all clusters';
+        }
+        parkDetail(); // rows are rebuilt below — keep the panel safe
+
+        scheduleMap();
+
+        if ( ! list.length ) {
+            clusterList.innerHTML = '<tr><td colspan="6" class="clusterlane-browse__empty">No clusters match these filters.</td></tr>';
+            return;
+        }
+
+        document.querySelectorAll( '#clusterlane-ci-group button[data-group]' ).forEach( b => {
+            const on = b.dataset.group === ciTable.group;
+            b.classList.toggle( 'is-active', on );
+            b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
+        } );
+        const rowHtml = c => {
+            const h      = +c.health_score;
+            const orph   = +c.orphans || 0;
+            const active = c.cluster_key === currentKey ? ' clusterlane-ci-cluster-row--active' : '';
+            return `<tr class="clusterlane-ci-cluster-row${ active }" data-key="${ esc( c.cluster_key ) }" data-type="${ esc( c.cluster_type ) }"${ grouped() && canManageList ? ' draggable="true"' : '' }>
+                <td class="clusterlane-browse__col-title">
+                    <span class="clusterlane-ci-cluster-type" title="${ esc( clusterlaneType( c.cluster_type ).label ) }">${ clusterlaneGlyph( clusterlaneType( c.cluster_type ).glyph ) }</span><button type="button" class="clusterlane-browse__open">${ esc( c.cluster_label ) }</button><button type="button" class="clusterlane-ci-quick" data-clusterlane-quick="${ esc( c.cluster_key ) }" data-clusterlane-quick-label="${ esc( c.cluster_label ) }" title="Quick view" aria-label="Quick view: ${ esc( c.cluster_label ) }">${ clusterlaneGlyph( 'eye' ) }</button>
+                </td>
+                <td class="clusterlane-browse__opt">${ esc( c.archetype || 'Unclassified' ) }</td>
+                <td class="num">${ +c.total_members }</td>
+                <td class="num${ orph > 0 ? ' clusterlane-browse__zero' : ' clusterlane-browse__muted' }">${ orph }</td>
+                <td class="num clusterlane-browse__opt">${ +c.pillars || 0 }</td>
+                <td class="num"><span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--${ bandOf( h ) }"><span class="clusterlane-ci-health-node" aria-hidden="true"></span>${ h }%</span></td>
+            </tr>`;
+        };
+        if ( ! grouped() ) {
+            clusterList.innerHTML = list.map( rowHtml ).join( '' );
+        } else {
+            // Sections in order (your groups, the menu, categories, Other); within each, the table's own sort.
+            const bySec = {};
+            list.forEach( c => ( bySec[ sectionOf( c ) ] = bySec[ sectionOf( c ) ] || [] ).push( c ) );
+            const order = sections.map( x => x.key ).concat( Object.keys( bySec ).filter( k => ! sections.some( x => x.key === k ) ) );
+            // Empty groups of your own show too (a place to move clusters into) — unless a filter narrows the list.
+            const narrowed = list.length !== allClusters.length;
+            const groups   = sections.filter( x => x.source === 'group' );
+            clusterList.innerHTML = order.filter( k => bySec[ k ] || ( ! narrowed && groups.some( g => g.key === k ) ) ).map( k => {
+                const rows = bySec[ k ] || [];
+                const sec  = sections.find( x => x.key === k ) || { key: k, label: 'Other' };
+                const mine = sec.source === 'group';
+                const gi   = groups.findIndex( g => g.key === k );
+                const arts = rows.reduce( ( n, c ) => n + ( +c.total_members || 0 ), 0 );
+                // Weighted by articles, like the site's health on the Dashboard.
+                const hs   = rows.filter( c => +c.total_members > 0 );
+                const avg  = arts ? Math.round( hs.reduce( ( a, c ) => a + ( +c.health_score ) * ( +c.total_members ), 0 ) / arts ) : 0;
+                const shut = folded.has( k ) && ! rows.some( c => c.cluster_key === currentKey );
+                const meta = rows.length
+                    ? `${ rows.length } ${ rows.length === 1 ? 'cluster' : 'clusters' } · ${ arts } articles`
+                    : ( canManageList ? 'Empty — drag clusters here, or pick this group under Section when a cluster is open' : 'Empty' );
+                const acts = mine && canManageList ? `<span class="clusterlane-ci-section-acts">
+                        <button type="button" data-grp="move" data-dir="-1" title="Move up" aria-label="Move ${ esc( sec.label ) } up"${ gi <= 0 ? ' disabled' : '' }>↑</button>
+                        <button type="button" data-grp="move" data-dir="1" title="Move down" aria-label="Move ${ esc( sec.label ) } down"${ gi >= groups.length - 1 ? ' disabled' : '' }>↓</button>
+                        <button type="button" data-grp="rename" title="Rename" aria-label="Rename ${ esc( sec.label ) }">${ clusterlaneGlyph( 'pencil' ) }</button>
+                        <button type="button" data-grp="delete" title="Delete group" aria-label="Delete ${ esc( sec.label ) }">×</button>
+                    </span>` : '';
+                const head = `<tr class="clusterlane-ci-section-row${ shut ? ' is-folded' : '' }${ mine ? ' is-mine' : '' }" data-section="${ esc( k ) }">
+                    <td colspan="6"><div class="clusterlane-ci-section-bar"><button type="button" class="clusterlane-ci-section-toggle" aria-expanded="${ shut ? 'false' : 'true' }">
+                        <span class="clusterlane-ci-section-caret" aria-hidden="true"></span>
+                        <span class="clusterlane-ci-section-name">${ esc( sec.label ) }</span>
+                        <span class="clusterlane-ci-section-meta">${ esc( meta ) }</span>
+                    </button>${ acts }
+                    ${ hs.length ? `<span class="clusterlane-ci-section-health clusterlane-ci-health-cell clusterlane-ci-detail-health--${ bandOf( avg ) }"><span class="clusterlane-ci-health-node" aria-hidden="true"></span>${ avg }%</span>` : '' }</div></td></tr>`;
+                return head + ( shut ? '' : rows.map( rowHtml ).join( '' ) );
+            } ).join( '' );
+        }
+
+        // Re-attach the open cluster's panel under its row (if it is still listed).
+        if ( currentKey && detailPanel && detailPanel.style.display !== 'none' ) placeDetail( currentKey );
+    }
+
+    // Row → detail (event delegation: rows are re-rendered on every sort/filter).
+    if ( clusterList ) clusterList.addEventListener( 'click', e => {
+        const act = e.target.closest( 'button[data-grp]' );
+        if ( act ) {
+            const k = act.closest( 'tr' ).dataset.section;
+            const g = sections.find( x => x.key === k ) || {};
+            const a = act.dataset.grp;
+            if ( a === 'rename' ) {
+                const label = window.prompt( 'Rename the group:', g.label || '' );
+                if ( label && label.trim() ) editGroup( { action: 'rename', key: k, label: label.trim() } );
+            } else if ( a === 'delete' ) {
+                const n = allClusters.filter( c => sectionOf( c ) === k ).length;
+                if ( window.confirm( 'Delete the group “' + g.label + '”?' + ( n ? ' Its ' + n + ( n === 1 ? ' cluster goes' : ' clusters go' ) + ' back to their automatic section.' : '' ) ) ) editGroup( { action: 'delete', key: k } );
+            } else if ( a === 'move' ) {
+                editGroup( { action: 'move', key: k, dir: +act.dataset.dir } );
+            }
+            return;
+        }
+        const sec = e.target.closest( 'tr.clusterlane-ci-section-row' );
+        if ( sec ) {
+            const k = sec.dataset.section;
+            folded.has( k ) ? folded.delete( k ) : folded.add( k );
+            try { localStorage.setItem( FOLD_KEY, JSON.stringify( [ ...folded ] ) ); } catch ( err ) {}
+            renderClusterList();
+            return;
+        }
+        const row = e.target.closest( 'tr.clusterlane-ci-cluster-row' );
+        if ( ! row || e.target.closest( '.clusterlane-tip' ) ) return;
+        const key = row.dataset.key;
+        document.querySelectorAll( '.clusterlane-ci-cluster-row' ).forEach( r =>
+            r.classList.toggle( 'clusterlane-ci-cluster-row--active', r.dataset.key === key )
+        );
+        loadDetail( key );
+        if ( visNetwork ) {
+            try { visNetwork.selectNodes( [ key ], true ); } catch ( err ) { /* node filtered out — ignore */ }
+        }
+    } );
+    const ciThead = document.querySelector( '.clusterlane-ci-table thead' );
+    if ( ciThead ) ciThead.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-sort]' );
+        if ( ! b ) return;
+        const k = b.dataset.sort;
+        if ( ciTable.sort === k ) ciTable.dir = ciTable.dir === 'asc' ? 'desc' : 'asc';
+        else { ciTable.sort = k; ciTable.dir = ( k === 'label' || k === 'archetype' || k === 'health' ) ? 'asc' : 'desc'; }
+        renderClusterList();
+    } );
+    const ciGroup = document.getElementById( 'clusterlane-ci-group' );
+    if ( ciGroup ) ciGroup.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-group]' );
+        if ( ! b ) return;
+        ciTable.group = b.dataset.group;
+        try { localStorage.setItem( GROUP_KEY, ciTable.group ); } catch ( err ) {}
+        renderClusterList();
+    } );
+    async function sectionsPost( path, body ) {
+        const r = await fetch( REST + 'cluster-inspector/' + path, {
+            method: 'POST', headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' },
+            body: JSON.stringify( body ),
+        } );
+        const d = await r.json();
+        if ( ! r.ok ) throw new Error( d.error || 'Failed' );
+        setSections( d );
+        renderClusterList();
+        refreshPicker();
+        return d;
+    }
+    async function editGroup( body ) {
+        try { return await sectionsPost( 'section-group', body ); }
+        catch ( err ) { window.alert( 'Could not change the group: ' + err.message ); return null; }
+    }
+    function newGroup( assign ) {
+        const label = window.prompt( 'Name of the new group:' );
+        if ( ! label || ! label.trim() ) return null;
+        if ( ciTable.group !== 'section' ) { ciTable.group = 'section'; try { localStorage.setItem( GROUP_KEY, 'section' ); } catch ( e ) {} }
+        return editGroup( Object.assign( { action: 'add', label: label.trim() }, assign ? { assign } : {} ) );
+    }
+    function refreshPicker() {
+        const lab = document.querySelector( '.clusterlane-ci-section-pick' );
+        if ( lab ) lab.outerHTML = sectionPickerHtml();
+    }
+    const newGroupBtn = document.getElementById( 'clusterlane-ci-new-group' );
+    if ( newGroupBtn ) {
+        if ( ! canManageList ) newGroupBtn.remove();
+        else newGroupBtn.addEventListener( 'click', () => newGroup( '' ) );
+    }
+
+    // Drag a cluster row onto a section header to move it there (admins, grouped view).
+    let dragKey = '';
+    if ( clusterList ) {
+        clusterList.addEventListener( 'dragstart', e => {
+            const row = e.target.closest && e.target.closest( 'tr.clusterlane-ci-cluster-row[draggable]' );
+            if ( ! row ) return;
+            dragKey = row.dataset.key;
+            e.dataTransfer.effectAllowed = 'move';
+            try { e.dataTransfer.setData( 'text/plain', dragKey ); } catch ( err ) {}
+            row.classList.add( 'is-dragging' );
+            clusterList.classList.add( 'is-dragging' );
+        } );
+        clusterList.addEventListener( 'dragend', () => {
+            dragKey = '';
+            clusterList.classList.remove( 'is-dragging' );
+            clusterList.querySelectorAll( '.is-dragging, .is-drop' ).forEach( x => x.classList.remove( 'is-dragging', 'is-drop' ) );
+        } );
+        clusterList.addEventListener( 'dragover', e => {
+            if ( ! dragKey ) return;
+            const head = e.target.closest( 'tr.clusterlane-ci-section-row' );
+            clusterList.querySelectorAll( '.is-drop' ).forEach( x => { if ( x !== head ) x.classList.remove( 'is-drop' ); } );
+            if ( ! head ) return;
+            e.preventDefault();
+            e.dataTransfer.dropEffect = 'move';
+            head.classList.add( 'is-drop' );
+        } );
+        clusterList.addEventListener( 'drop', async e => {
+            const head = e.target.closest( 'tr.clusterlane-ci-section-row' );
+            if ( ! dragKey || ! head ) return;
+            e.preventDefault();
+            const key = dragKey, to = head.dataset.section;
+            dragKey = '';
+            if ( sectionOf( allClusters.find( c => c.cluster_key === key ) || {} ) === to ) return;
+            folded.delete( to );
+            try { await sectionsPost( 'section', { key, section: to } ); }
+            catch ( err ) { window.alert( 'Could not move the cluster: ' + err.message ); }
+        } );
+    }
+
+    // Export CSV (0.98.166): the clusters as listed — same search and filters, sections in their order.
+    const ciCsv = document.getElementById( 'clusterlane-ci-csv' );
+    if ( ciCsv ) ciCsv.addEventListener( 'click', () => {
+        let rows = filtered();
+        if ( grouped() ) {
+            const order = sections.map( x => x.key );
+            const pos   = k => { const i = order.indexOf( k ); return i === -1 ? order.length : i; };
+            rows = rows.map( ( c, i ) => [ c, i ] ).sort( ( a, b ) => ( pos( sectionOf( a[0] ) ) - pos( sectionOf( b[0] ) ) ) || ( a[1] - b[1] ) ).map( x => x[0] );
+        }
+        const secName = c => ( sections.find( x => x.key === sectionOf( c ) ) || { label: 'Other' } ).label;
+        window.clusterlaneCsv( 'clusterlane-clusters', [
+            [ 'Section', secName ], [ 'Cluster', c => c.cluster_label ], [ 'Type', c => clusterlaneType( c.cluster_type ).label ],
+            [ 'Archetype', c => c.archetype || 'Unclassified' ], [ 'Articles', c => +c.total_members ], [ 'Orphans', c => +c.orphans || 0 ],
+            [ 'Pillars', c => +c.pillars || 0 ], [ 'Supports', c => +c.supports || 0 ], [ 'Light', c => +c.lights || 0 ],
+            [ 'Health', c => +c.health_score ], [ 'Entropy', c => c.entropy_score ], [ 'Gini', c => c.gini_score ], [ 'Density', c => c.density_score ],
+        ], rows );
+    } );
+    const ciBands = document.getElementById( 'clusterlane-ci-bands' );
+    if ( ciBands ) ciBands.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-band]' );
+        if ( ! b ) return;
+        ciTable.band = b.dataset.band;
+        renderClusterList();
+    } );
+
+
+    /* ── Collapsible cards (remember state, refit graph on open) ─────── */
+    document.querySelectorAll( 'details.clusterlane-collapse[data-collapse-key]' ).forEach( d => {
+        const k = 'clusterlane-collapse-' + d.dataset.collapseKey;
+        try { const v = localStorage.getItem( k ); if ( v === '0' ) d.open = false; if ( v === '1' ) d.open = true; } catch ( e ) {}
+        d.addEventListener( 'toggle', () => {
+            try { localStorage.setItem( k, d.open ? '1' : '0' ); } catch ( e ) {}
+            if ( d.open && d.id === 'clusterlane-ci-list-card' && view === 'map' && visNetwork ) {
+                // Canvas was sized while hidden — re-measure now that it is visible.
+                requestAnimationFrame( () => { visNetwork.redraw(); visNetwork.fit(); } );
+            }
+        } );
+    } );
+
+    // Filters + search + sort.
+    if ( searchInput ) searchInput.addEventListener( 'input', renderClusterList );
+    typeFilters.forEach( btn => {
+        btn.addEventListener( 'click', () => {
+            const t = btn.dataset.type;
+            if ( activeFilters.has( t ) ) { activeFilters.delete( t ); btn.classList.remove( 'active' ); }
+            else                          { activeFilters.add( t );    btn.classList.add( 'active' );    }
+            renderClusterList();
+        } );
+    } );
+
+    /* ------------------------------------------------------------------
+       Inline detail
+    ------------------------------------------------------------------ */
+
+    async function loadDetail( key ) {
+        if ( key === currentKey ) {
+            // Toggle — close if same cluster clicked again.
+            detailPanel.style.display = 'none';
+            currentKey = null;
+            parkDetail();
+            if ( improveWrap ) improveWrap.style.display = 'none';
+            if ( gapCount ) gapCount.style.display = 'none';
+            if ( gapHeader ) gapHeader.classList.remove( 'is-open' );
+            if ( gapBody ) { gapBody.classList.remove( 'is-open' ); gapBody.innerHTML = ''; }
+            if ( window.clusterlaneSplit ) window.clusterlaneSplit.reset();
+            if ( tmsCount ) tmsCount.style.display = 'none';
+            if ( tmsHeader ) tmsHeader.classList.remove( 'is-open' );
+            if ( tmsBody ) { tmsBody.classList.remove( 'is-open' ); tmsBody.innerHTML = ''; }
+            if ( ailinkCount ) ailinkCount.style.display = 'none';
+            if ( ailinkHeader ) ailinkHeader.classList.remove( 'is-open' );
+            if ( ailinkBody ) { ailinkBody.classList.remove( 'is-open' ); ailinkBody.innerHTML = ''; }
+            document.querySelectorAll( '.clusterlane-ci-cluster-row' ).forEach( r => r.classList.remove( 'clusterlane-ci-cluster-row--active' ) );
+            return;
+        }
+        currentKey = key;
+        detailPanel.style.display = '';
+        const openedRow = placeDetail( key );
+        detailLabel.textContent   = '…';
+        renderDetailHead( null );
+        detailStats.innerHTML     = '';
+        detailDist.innerHTML      = '';
+        detailMembers.innerHTML   = '<p style="color:#94a3b8;font-size:13px;">Loading…</p>';
+        resetImproveSummary();
+        // Reset AI panel for new cluster.
+        if ( aiResult ) { aiResult.style.display = 'none'; aiResult.innerHTML = ''; }
+        if ( aiBtn ) { aiBtn.disabled = false; aiBtn.innerHTML = '' + clusterlaneGlyph( 'sparkle' ) + 'Analyse cluster'; }
+        // Reset graph.
+        if ( detailVisNetwork ) { detailVisNetwork.destroy(); detailVisNetwork = null; }
+        lastGraphData      = null;
+        lastGraphPositions = null;
+        if ( graphOverlay ) { graphOverlay.style.display = ''; }
+        if ( graphStatus )  { graphStatus.textContent = 'Loading…'; }
+        scrollToOpened( openedRow );
+
+        try {
+            const r    = await fetch( REST + 'cluster-inspector/detail/' + encodeURIComponent( key ), {
+                headers: { 'X-WP-Nonce': NONCE },
+            } );
+            const data = await r.json();
+            renderDetail( data );
+            loadClusterGraph( key );
+            loadGapAnalysis( key );
+            if ( window.clusterlaneSplit ) window.clusterlaneSplit.open( data.cluster || null );
+            const memberIds = ( data.members || [] ).map( m => m.id || m.post_id ).filter( Boolean );
+            loadTmsMatches( key, memberIds );
+            loadAiLinkSuggestions( key, data.members || [] );
+        } catch ( err ) {
+            detailMembers.innerHTML = '<p style="color:#ef4444;">Failed to load: ' + esc( err.message ) + '</p>';
+        }
+    }
+
+    function renderDetail( data ) {
+        const c = data.cluster;
+        if ( ! c ) return;
+
+        // Header: the cluster is the headline; its health is the lit node.
+        detailLabel.textContent = c.label;
+        currentLabel = c.label;
+        renderDetailHead( c );
+
+        // Info bar in header — rendered immediately from detail data (Links added when graph loads).
+        buildGraphInfoBar( c, null, graphInlineInfobar );
+
+        // Class distribution bar.
+        const stats  = data.stats || {};
+        const total  = Object.values( stats ).reduce( ( s, v ) => s + v, 0 ) || 1;
+        const ORDER  = [ 'pillar', 'support', 'light', 'orphan' ];
+        const LABELS = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+        detailDist.innerHTML = `
+            <div style="display:flex;height:10px;border-radius:999px;overflow:hidden;gap:1px;margin-bottom:6px;">
+                ${ ORDER.map( cls => {
+                    const pct = Math.round( ( stats[cls] || 0 ) / total * 100 );
+                    const col = CLASS_COLORS[cls]?.border || '#ccc';
+                    return pct ? `<div style="width:${pct}%;background:${col};" title="${LABELS[cls]}: ${stats[cls] || 0}"></div>` : '';
+                } ).join('') }
+            </div>
+            <div style="display:flex;gap:12px;flex-wrap:wrap;">
+                ${ ORDER.map( cls => {
+                    const col = CLASS_COLORS[cls] || {};
+                    return `<span style="font-size:11px;color:${col.text||'#555'};display:flex;align-items:center;gap:4px;">
+                        <span style="width:8px;height:8px;border-radius:50%;background:${col.border||'#ccc'};display:inline-block;"></span>
+                        ${LABELS[cls]} <strong>${stats[cls]||0}</strong>
+                    </span>`;
+                } ).join('') }
+            </div>`;
+
+        // Member table.
+        const members = data.members || [];
+        improveState.orphan = members.filter( m => m.content_class === 'orphan' ).length;
+        improveState.light  = members.filter( m => m.content_class === 'light' ).length;
+        renderImproveSummary();
+        if ( ! members.length ) {
+            detailMembers.innerHTML = '<p style="color:#94a3b8;font-size:13px;">No members found.</p>';
+            return;
+        }
+
+        // Auto-render cached AI analysis if present.
+        if ( data.ai && aiResult ) {
+            renderAiResult( data.ai, c.key );
+            aiResult.style.display = '';
+            if ( aiBtn ) {
+                aiBtn.disabled = false;
+                aiBtn.innerHTML = '' + clusterlaneGlyph( 'sparkle' ) + 'Regenerate';
+                aiBtn.onclick = () => loadAiAnalysis( currentKey, true );
+            }
+        }
+
+        // Warn if some members haven't been scanned yet.
+        const unscanned = data.unscanned || 0;
+        const warnHtml = unscanned > 0
+            ? `<div style="background:#fef9c3;border:1px solid #fde047;border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#854d0e;">
+                ${ clusterlaneGlyph( 'alert' ) }<strong>${unscanned} article${unscanned > 1 ? 's' : ''}</strong> in this cluster ${unscanned > 1 ? 'have' : 'has'} not been scanned yet — click <strong>Scan</strong> in the bar at the top to see their metrics.
+               </div>`
+            : '';
+
+        membersData = members;
+        chosenPillar = +data.chosen_pillar || 0;
+        pillarNotice = '';
+        const mc = document.getElementById( 'clusterlane-ci-members-count' );
+        if ( mc ) mc.textContent = '(' + members.length + ')';
+        membersSort = { key: 'role', dir: 'asc' };
+        detailMembers.dataset.warn = warnHtml;
+        renderMembers();
+    }
+
+    /* ── Members table (same component as the cluster table; sortable) ── */
+    let membersData = [], membersSort = { key: 'role', dir: 'asc' }, chosenPillar = 0, pillarNotice = '';
+
+    /** What "Make pillar" just did, in words (0.98.137): which article is the pillar now, instead of which, and where the link boxes point. */
+    function pillarMessage( before, now, d ) {
+        const t = id => { const m = membersData.find( x => +x.id === +id ); return m ? '“' + truncTitle( m.title ) + '”' : 'The article'; };
+        const box = d.pillar_box && d.pillar_box.on;
+        let msg;
+        if ( now && before && before !== now ) msg = t( now ) + ' is now the pillar instead of ' + t( before ) + '.';
+        else if ( now ) msg = t( now ) + ' is now the pillar of this cluster.';
+        else msg = t( before ) + ' is no longer the chosen pillar — Clusterlane picks the best-linked article again.';
+        if ( box ) {
+            const n = +d.pillar_box.articles || 0;
+            msg += now
+                ? ( n ? ( n === 1 ? ' The link box on 1 article now points here.' : ' The link boxes on ' + n + ' articles now point here.' ) : ' No article shows the link box yet — they all link to it in the text already.' )
+                : ' Its link boxes are removed; this cluster gets none without a chosen pillar.';
+        }
+        return msg;
+    }
+    const canManage = parseInt( clusterlaneData.canManage, 10 ) === 1;
+    const ROLE_ORDER = { pillar: 0, support: 1, light: 2, orphan: 3 };
+    const ROLE_NAME  = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+    function renderMembers() {
+        if ( ! detailMembers ) return;
+        const k = membersSort.key, dir = membersSort.dir === 'asc' ? 1 : -1;
+        const val = {
+            title: m => String( m.title || '' ).toLowerCase(), role: m => ROLE_ORDER[ m.content_class ] ?? 9,
+            inbound: m => +m.inbound_links || 0, outbound: m => +m.outbound_links || 0, score: m => +m.overall_score || 0, words: m => +m.word_count || 0,
+        }[ k ];
+        const list = membersData.slice().sort( ( a, b ) => { const x = val( a ), y = val( b ); return ( x < y ? -dir : x > y ? dir : 0 ) || ( ( +b.overall_score || 0 ) - ( +a.overall_score || 0 ) ); } );
+        const th = ( key, label, tipKey, num ) => `<th class="${ num ? 'num' : '' }${ key === 'title' ? ' clusterlane-browse__col-title' : '' }" aria-sort="${ k === key ? ( membersSort.dir === 'asc' ? 'ascending' : 'descending' ) : 'none' }"><button type="button" data-msort="${ key }" class="${ k === key ? 'is-sorted' : '' }" data-dir="${ k === key ? membersSort.dir : '' }">${ label }</button>${ tipKey ? tip( tipKey ) : '' }</th>`;
+        const band = n => n >= 80 ? 'good' : n >= 60 ? 'fair' : 'weak';
+        detailMembers.innerHTML = ( detailMembers.dataset.warn || '' )
+            + ( pillarNotice ? `<div class="clusterlane-ci-pillar-notice" role="status">${ clusterlaneGlyph( 'award' ) }<span>${ esc( pillarNotice ) }</span><button type="button" class="clusterlane-ci-pillar-notice__close" data-close-notice aria-label="Close">×</button></div>` : '' ) + `
+            <div class="clusterlane-browse__table-wrap clusterlane-ci-members-wrap"><table class="clusterlane-browse__table clusterlane-ci-members">
+                <thead><tr>${ th( 'title', 'Article' ) }${ th( 'role', 'Role', 'role' ) }${ th( 'inbound', 'In', 'inbound', 1 ) }${ th( 'outbound', 'Out', 'outbound', 1 ) }${ th( 'score', 'Score', 'overall_score', 1 ) }${ th( 'words', 'Words', 'word_count', 1 ) }<th></th></tr></thead>
+                <tbody>${ list.map( m => `<tr class="clusterlane-ci-member-row" data-href="${ esc( clusterlaneData.inspectorUrl + '&post_id=' + m.id + '&from_cluster=' + encodeURIComponent( currentKey || '' ) + '&from_cluster_label=' + encodeURIComponent( currentLabel || '' ) ) }" tabindex="0">
+                    <td class="clusterlane-browse__col-title"><div class="clusterlane-browse__titlecell">${ clusterlaneThumb( m.thumb || '', m.title, m.content_class, 'sm' ) }<a class="clusterlane-ci-member-link" href="${ esc( m.url ) }" target="_blank" rel="noopener">${ esc( truncTitle( m.title ) ) }</a></div></td>
+                    <td>${ ROLE_NAME[ m.content_class ] ? `<span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--${ m.content_class }"></span>${ ROLE_NAME[ m.content_class ] }</span>` : '—' }${ +m.id === chosenPillar ? ' <span class="clusterlane-ci-chosen" title="Chosen as this cluster’s pillar">chosen</span>' : '' }</td>
+                    <td class="num${ +m.inbound_links === 0 ? ' clusterlane-browse__zero' : '' }">${ +m.inbound_links || 0 }</td>
+                    <td class="num">${ +m.outbound_links || 0 }</td>
+                    <td class="num"><span class="clusterlane-browse__score clusterlane-browse__score--${ band( +m.overall_score || 0 ) }">${ +m.overall_score || 0 }</span></td>
+                    <td class="num">${ ( +m.word_count || 0 ).toLocaleString() }</td>
+                    <td class="clusterlane-ci-member-actions">
+                        ${ canManage ? ( +m.id === chosenPillar
+                            ? `<button type="button" class="clusterlane-tm-btn clusterlane-ci-pillar-btn is-chosen" data-pillar="0" title="Stop using this as the pillar — Clusterlane picks the best-linked article again" aria-label="Unset ${ esc( m.title ) } as pillar">${ clusterlaneGlyph( 'award' ) }</button>`
+                            : `<button type="button" class="clusterlane-tm-btn clusterlane-ci-pillar-btn" data-pillar="${ +m.id }" title="Make this the cluster’s pillar (main article)" aria-label="Make ${ esc( m.title ) } the pillar">${ clusterlaneGlyph( 'award' ) }</button>` ) : '' }
+                        <a href="${ esc( m.edit_url ) }" class="clusterlane-tm-btn" title="Edit in WordPress" aria-label="Edit ${ esc( m.title ) }">${ clusterlaneGlyph( 'pencil' ) }</a>
+                        <a href="${ clusterlaneData.inspectorUrl }&post_id=${ m.id }&from_cluster=${ encodeURIComponent( currentKey || '' ) }&from_cluster_label=${ encodeURIComponent( currentLabel || '' ) }" class="clusterlane-tm-btn" title="Inspect in Article Inspector" aria-label="Inspect ${ esc( m.title ) }">${ clusterlaneGlyph( 'search' ) }</a>
+                    </td></tr>` ).join( '' ) }</tbody>
+            </table></div>`;
+    }
+    // A member row opens that article in Article Inspector (links/buttons inside keep their own action).
+    function openMember( e ) {
+        const tr = e.target.closest( 'tr.clusterlane-ci-member-row' );
+        if ( ! tr || e.target.closest( 'a, button, .clusterlane-tip' ) ) return false;
+        window.location.href = tr.dataset.href;
+        return true;
+    }
+    if ( detailMembers ) detailMembers.addEventListener( 'keydown', e => { if ( e.key === 'Enter' ) openMember( e ); } );
+    if ( detailMembers ) detailMembers.addEventListener( 'click', async e => {
+        const pb = e.target.closest( 'button[data-pillar]' );
+        if ( pb && currentKey ) {
+            pb.disabled = true;
+            const key = currentKey;
+            try {
+                const r = await fetch( REST + 'cluster-inspector/pillar', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': clusterlaneData.nonce }, body: JSON.stringify( { key, post_id: parseInt( pb.dataset.pillar, 10 ) } ) } );
+                const d = await r.json().catch( () => ( {} ) );
+                if ( ! r.ok ) throw new Error( d.error || d.message || ( 'HTTP ' + r.status ) );
+                // Fresh roles for the table and the graph (the cluster was recomputed).
+                const fr = await fetch( REST + 'cluster-inspector/detail/' + encodeURIComponent( key ), { headers: { 'X-WP-Nonce': clusterlaneData.nonce } } );
+                const fd = await fr.json();
+                if ( key !== currentKey ) return;
+                pillarNotice = pillarMessage( chosenPillar, +d.chosen_pillar || 0, d ); // titles from the list before it refreshes
+                membersData = fd.members || membersData;
+                chosenPillar = +fd.chosen_pillar || 0;
+                renderMembers();
+                if ( typeof loadClusterGraph === 'function' ) loadClusterGraph( key );
+            } catch ( err ) { pb.disabled = false; alert( err.message ); }
+            return;
+        }
+        if ( e.target.closest( '[data-close-notice]' ) ) { pillarNotice = ''; renderMembers(); return; }
+        if ( openMember( e ) ) return;
+        const b = e.target.closest( 'button[data-msort]' ); if ( ! b ) return;
+        const key = b.dataset.msort;
+        membersSort = membersSort.key === key ? { key, dir: membersSort.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: ( key === 'title' || key === 'role' || key === 'inbound' || key === 'score' ) ? 'asc' : 'desc' };
+        renderMembers();
+    } );
+
+    if ( detailClose ) {
+        const backBtn = document.getElementById( 'clusterlane-ci-detail-back' );
+        if ( backBtn ) backBtn.onclick = () => {
+            const key = currentKey;
+            detailClose.onclick();
+            // Back to where the cluster sits in the list, with focus on its name.
+            const row = key && clusterList ? [ ...clusterList.querySelectorAll( 'tr.clusterlane-ci-cluster-row' ) ].find( r => r.dataset.key === key ) : null;
+            if ( row ) { row.scrollIntoView( { block: 'center' } ); const b = row.querySelector( '.clusterlane-browse__open' ); if ( b ) b.focus( { preventScroll: true } ); }
+        };
+        detailClose.onclick = () => {
+            detailPanel.style.display = 'none';
+            currentKey = null;
+            parkDetail();
+            if ( improveWrap ) improveWrap.style.display = 'none';
+            if ( gapCount ) gapCount.style.display = 'none';
+            if ( gapHeader ) gapHeader.classList.remove( 'is-open' );
+            if ( gapBody ) { gapBody.classList.remove( 'is-open' ); gapBody.innerHTML = ''; }
+            if ( window.clusterlaneSplit ) window.clusterlaneSplit.reset();
+            if ( tmsCount ) tmsCount.style.display = 'none';
+            if ( tmsHeader ) tmsHeader.classList.remove( 'is-open' );
+            if ( tmsBody ) { tmsBody.classList.remove( 'is-open' ); tmsBody.innerHTML = ''; }
+            if ( ailinkCount ) ailinkCount.style.display = 'none';
+            if ( ailinkHeader ) ailinkHeader.classList.remove( 'is-open' );
+            if ( ailinkBody ) { ailinkBody.classList.remove( 'is-open' ); ailinkBody.innerHTML = ''; }
+            document.querySelectorAll( '.clusterlane-ci-cluster-row' ).forEach( r => r.classList.remove( 'clusterlane-ci-cluster-row--active' ) );
+            if ( visNetwork ) visNetwork.unselectAll();
+        };
+    }
+
+    /* ------------------------------------------------------------------
+       Graph info bar — shared helper
+    ------------------------------------------------------------------ */
+
+
+    /** Kind line above the name + health node beside it (detail header). */
+    function renderDetailHead( c ) {
+        const kindEl   = document.getElementById( 'clusterlane-ci-detail-kind' );
+        const healthEl = document.getElementById( 'clusterlane-ci-detail-health' );
+        if ( kindEl ) {
+            kindEl.textContent = c ? `${ clusterlaneType( c.type ).label } cluster with ${ c.members } ${ clusterlaneType( c.type ).noun }${ c.members === 1 ? '' : 's' }` : '';
+        }
+        if ( ! healthEl ) return;
+        if ( ! c ) { healthEl.hidden = true; healthEl.innerHTML = ''; return; }
+        const h     = Number( c.health ) || 0;
+        const state = h >= 70 ? 'good' : h >= 50 ? 'fair' : 'weak';
+        healthEl.className = 'clusterlane-ci-detail-health clusterlane-ci-detail-health--' + state;
+        healthEl.innerHTML = `<span class="clusterlane-ci-health-node" aria-hidden="true"></span>
+            <span class="clusterlane-ci-health-value">${ esc( String( h ) ) }%</span>
+            <span class="clusterlane-ci-health-label">health${ tip( 'health' ) }</span>`;
+        healthEl.hidden = false;
+    }
+
+    // "Links" = links between the cluster's articles (0.98.200) — not the archive page's
+    // structural edges or links to it, so the figure matches the quick view and the truncated count.
+    function articleLinks( edges ) {
+        return edges.filter( e => typeof e.from === 'number' && typeof e.to === 'number' ).length;
+    }
+
+    function buildGraphInfoBar( cluster, edgeCount, target ) {
+        if ( ! target || ! cluster ) return;
+
+        const typeIcon   = clusterlaneGlyph( clusterlaneType( cluster.type ).glyph );
+        const typeLabel  = clusterlaneType( cluster.type ).label;
+        const hCol       = cluster.health >= 70 ? '#16a34a' : cluster.health >= 50 ? '#ca8a04' : '#dc2626';
+        const arch       = cluster.archetype || 'Unclassified';
+        const linksSpan  = edgeCount !== null
+            ? `<span>Links: <strong>${esc( String( edgeCount ) )}</strong></span>`
+            : '';
+
+        target.innerHTML = `<div class="clusterlane-graph-infobar">
+            <span class="clusterlane-graph-infobar-type clusterlane-gib-head">${typeIcon} ${esc( typeLabel )}</span>
+            <span class="clusterlane-gib-head">${esc( String( cluster.members ) )} ${ clusterlaneType( cluster.type ).noun }s</span>
+            <span class="clusterlane-gib-head">Health${ tip( 'health' ) } <strong style="color:${hCol};">${esc( String( cluster.health ) )}%</strong></span>
+            <span class="clusterlane-gib-arch"><span class="clusterlane-graph-infobar-arch">${esc( arch )}</span>${ tip( 'archetype' ) }</span>
+            ${linksSpan}
+            <details class="clusterlane-gib-tech"${ techOpen() ? ' open' : '' }>
+                <summary>Structure figures</summary>
+                <span>Entropy${ tip( 'entropy' ) }: <strong>${esc( String( cluster.entropy ) )}</strong></span>
+                <span>Gini${ tip( 'gini' ) }: <strong>${esc( String( cluster.gini ) )}</strong></span>
+                <span>Density${ tip( 'density' ) }: <strong>${esc( String( cluster.density ) )}</strong></span>
+            </details>
+            ${ target === graphInlineInfobar ? sectionPickerHtml() : '' }
+        </div>`;
+    }
+
+    // Entropy, Gini and Density behind "Structure figures" (0.98.187): useful to
+    // technical users, noise for most editors. The choice is remembered.
+    function techOpen() { try { return localStorage.getItem( 'clusterlane-ci-tech' ) === '1'; } catch ( e ) { return false; } }
+    document.addEventListener( 'toggle', e => {
+        if ( ! e.target.classList || ! e.target.classList.contains( 'clusterlane-gib-tech' ) ) return;
+        try { localStorage.setItem( 'clusterlane-ci-tech', e.target.open ? '1' : '0' ); } catch ( err ) {}
+    }, true );
+
+    // "Section: [Museums ▾]" — admins can move a cluster to another section (0.98.160).
+    function sectionPickerHtml() {
+        const c = allClusters.find( x => x.cluster_key === currentKey );
+        if ( ! c || sections.length < 2 && sectionChoices.length < 2 ) return '';
+        const m    = sectionMap[ c.cluster_key ] || {};
+        const cur  = m.section || 'other';
+        const name = ( sections.find( x => x.key === cur ) || sectionChoices.find( x => x.key === cur ) || { label: 'Other' } ).label;
+        if ( ! canManageList ) return `<span>Section: <strong>${ esc( name ) }</strong></span>`;
+        const auto = m.how === 'manual' ? '' : ' selected';
+        const opt  = x => `<option value="${ esc( x.key ) }"${ m.how === 'manual' && x.key === cur ? ' selected' : '' }>${ esc( x.label ) }</option>`;
+        const mine = sectionChoices.filter( x => x.source === 'group' );
+        const site = sectionChoices.filter( x => x.source !== 'group' );
+        const opts = ( mine.length ? `<optgroup label="Your groups">${ mine.map( opt ).join( '' ) }</optgroup>` : '' )
+            + ( site.length ? `<optgroup label="${ mine.length ? 'Site sections' : 'Sections' }">${ site.map( opt ).join( '' ) }` : '' );
+        const autoLabel = m.how === 'manual' ? 'Automatic' : 'Automatic (' + name + ')';
+        return `<label class="clusterlane-ci-section-pick">Section <select data-section-pick="${ esc( c.cluster_key ) }">
+            <option value=""${ auto }>${ esc( autoLabel ) }</option>${ opts }
+            <option value="other"${ m.how === 'manual' && cur === 'other' ? ' selected' : '' }>Other</option>${ site.length ? '</optgroup>' : '' }
+            <option value="__new">+ New group…</option></select></label>`;
+    }
+    document.addEventListener( 'change', async e => {
+        const sel = e.target.closest( 'select[data-section-pick]' );
+        if ( ! sel ) return;
+        if ( sel.value === '__new' ) {
+            const d = await newGroup( sel.dataset.sectionPick );
+            if ( ! d ) refreshPicker(); // cancelled: back to what it was
+            return;
+        }
+        sel.disabled = true;
+        try { await sectionsPost( 'section', { key: sel.dataset.sectionPick, section: sel.value } ); }
+        catch ( err ) {
+            sel.disabled = false;
+            window.alert( 'Could not save the section: ' + err.message );
+        }
+    } );
+
+    /* ------------------------------------------------------------------
+       Cluster graph (vis-network) — reuses clusterlane/v1/graph/{key}
+    ------------------------------------------------------------------ */
+
+    const ROLE_COLORS = {
+        pillar:  { bg: '#fff0e6', border: '#e76c2e' },
+        support: { bg: '#fffbe6', border: '#f5c842' },
+        light:   { bg: '#f0fdf4', border: '#90b865' },
+        orphan:  { bg: '#eff6ff', border: '#5ba4cf' },
+        hub:     { bg: '#f1f5f9', border: '#64748b' },
+    };
+
+    /**
+     * Builds a styled HTML tooltip element for a vis-network node.
+     * vis-network renders HTMLElement titles as-is, giving us full CSS control
+     * instead of the bare unstyled div it produces for plain-string titles.
+     */
+    function buildNodeTooltip( n ) {
+        const col   = ROLE_COLORS[ n.class ] || ROLE_COLORS.light;
+        const label = truncTitle( n.title && typeof n.title === 'string' ? n.title.split( '\n' )[ 0 ] : ( n.label || '' ) );
+        const role  = n.class || 'light';
+
+        // Role pill colours — dot colour from ROLE_COLORS border.
+        const dotColor = col.border;
+
+        // Read inbound + score from the server-supplied plain-text title string
+        // ("Post Title\nInbound: 9 | Score: 94") — more reliable than n.inbound
+        // which may be absent in some graph responses.
+        const titleStr = ( n.title && typeof n.title === 'string' ) ? n.title : '';
+        let inbound = n.inbound ?? 0;
+        const imatch = titleStr.match( /Inbound:\s*(\d+)/ );
+        if ( imatch ) inbound = parseInt( imatch[ 1 ], 10 );
+
+        const score = ( () => {
+            const m = titleStr.match( /Score:\s*(\d+)/ );
+            return m ? parseInt( m[ 1 ], 10 ) : null;
+        } )();
+
+        const scoreHtml = score !== null
+            ? `<span style="margin-left:8px;color:${ score >= 80 ? '#16a34a' : score >= 60 ? '#ca8a04' : '#dc2626' };font-weight:600;">${ score }%</span>`
+            : '';
+
+        // Hub nodes get a simpler layout.
+        const isHub = role === 'hub';
+        const statsHtml = isHub
+            ? `<div style="margin-top:5px;font-size:10px;color:#94a3b8;">${ n.title ? n.title.split( '\n' ).slice( 1 ).join( ' · ' ) : '' }</div>`
+            : `<div style="margin-top:6px;display:flex;align-items:center;gap:4px;font-size:10px;color:#94a3b8;">
+                <span style="display:inline-flex;align-items:center;gap:3px;">
+                    <span style="width:7px;height:7px;border-radius:50%;background:${ dotColor };flex-shrink:0;"></span>
+                    <span style="text-transform:capitalize;color:${ dotColor };font-weight:600;">${ role }</span>
+                </span>
+                <span style="color:#cbd5e1;">·</span>
+                <span>Inbound: <strong style="color:#f1f5f9;">${ inbound }</strong></span>
+                ${ scoreHtml ? `<span style="color:#cbd5e1;">·</span><span>Score:${ scoreHtml }</span>` : '' }
+               </div>`;
+
+        const el = document.createElement( 'div' );
+        el.style.cssText = [
+            'background:#1e2535',
+            'color:#e2e8f0',
+            'border-radius:7px',
+            'padding:8px 11px',
+            'min-width:180px',
+            'max-width:320px',
+            'box-shadow:0 4px 14px rgba(0,0,0,.35)',
+            'font-family:system-ui,sans-serif',
+            'pointer-events:none',
+            'line-height:1.4',
+        ].join( ';' );
+
+        el.innerHTML = ( n.thumb && n.class !== 'hub' && typeof window.clusterlaneThumbsOn === 'function' && window.clusterlaneThumbsOn() ? '<img class="clusterlane-graph-tip-img" src="' + esc( n.thumb ) + '" alt="">' : '' )
+            + `<div style="font-size:12px;font-weight:600;color:#f1f5f9;word-break:break-word;">${ esc( label ) }</div>`
+            + statsHtml;
+
+        return el;
+    }
+
+    async function loadClusterGraph( key ) {
+        if ( ! graphEl || typeof vis === 'undefined' ) return;
+
+        try {
+            const r    = await fetch( REST + 'graph/' + encodeURIComponent( key ), {
+                method: 'POST',
+                headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' },
+                body: '{}',
+            } );
+            const data = await r.json();
+
+            if ( ! data.nodes || ! data.nodes.length ) {
+                if ( graphStatus ) graphStatus.textContent = 'No graph data — click Scan in the bar at the top first.';
+                return;
+            }
+
+            lastGraphData = data; // cache for modal
+            if ( window.clusterlaneGraphNote ) window.clusterlaneGraphNote( graphEl, data );
+            renderClusterGraph( data );
+
+        } catch ( err ) {
+            if ( graphStatus ) graphStatus.textContent = 'Failed to load graph.';
+        }
+    }
+
+    function renderClusterGraph( data ) {
+        if ( ! graphEl ) return;
+
+        const rawNodes = structuredClone( data.nodes || [] );
+        const rawEdges = data.edges || [];
+
+        // Assign vis-style colours from role.
+        rawNodes.forEach( n => {
+            const col = ROLE_COLORS[ n.class ] || ROLE_COLORS.light;
+            n.color = { background: col.bg, border: col.border,
+                        highlight: { background: col.bg, border: '#1e293b' } };
+            n.font  = { size: 11, color: '#1e293b', face: 'system-ui,sans-serif', vadjust: -22 };
+            n.shape = 'dot';
+            n.size  = n.class === 'hub' ? 22 : 14;
+            n.borderWidth = n.class === 'hub' ? 3 : 1.5;
+            if ( n.class === 'hub' ) { n.x = 0; n.y = 0; n.fixed = { x: true, y: true }; }
+            // Apply display truncation — vis uses n.label for node text.
+            if ( n.label ) n.label = graphLabel( n );
+            // Replace plain-string title with a styled HTML element.
+            n.title = buildNodeTooltip( n );
+        } );
+
+        // Ring layout — same as dashboard.
+        const byGroup = { pillar: [], support: [], light: [], orphan: [] };
+        rawNodes.forEach( n => {
+            if ( n.class === 'hub' ) return;
+            const g = byGroup[ n.class ] ? n.class : 'light';
+            byGroup[ g ].push( n );
+        } );
+        const radii = { pillar: 100, support: 180, light: 260, orphan: 340 };
+        Object.entries( byGroup ).forEach( ( [ role, group ] ) => {
+            const r = Math.max( radii[ role ], group.length * ( role === 'pillar' ? 40 : role === 'support' ? 28 : 22 ) );
+            group.forEach( ( n, i ) => {
+                const angle = -Math.PI / 2 + ( 2 * Math.PI * i ) / Math.max( 1, group.length );
+                n.x = Math.cos( angle ) * r;
+                n.y = Math.sin( angle ) * r * 0.82;
+            } );
+        } );
+
+        if ( detailVisNetwork ) { detailVisNetwork.destroy(); detailVisNetwork = null; }
+
+        const nodes = new vis.DataSet( rawNodes );
+        const edges = new vis.DataSet( rawEdges );
+
+        detailVisNetwork = new vis.Network( graphEl, { nodes, edges }, {
+            layout:      { improvedLayout: false, randomSeed: 7 }, // same picture on every load (0.98.182)
+            nodes:       { shape: 'dot', size: 14, borderWidth: 1.5,
+                           font: { size: 11, color: '#333', vadjust: -22, face: 'system-ui,sans-serif' } },
+            edges:       { arrows: { to: { enabled: true, scaleFactor: 0.7 } },
+                           smooth: { type: 'curvedCW', roundness: 0.22 },
+                           width: 1.2, color: '#b0b7c3' },
+            physics:     { enabled: true, minVelocity: 0.75,
+                           stabilization: { iterations: 180 },
+                           barnesHut: { gravitationalConstant: -280, centralGravity: 0.12,
+                                        springLength: 160, springConstant: 0.04,
+                                        damping: 0.72, avoidOverlap: 1.0 } },
+            interaction: { hover: true, dragNodes: true, dragView: true, zoomView: false, tooltipDelay: 80 },
+        } );
+        clusterlaneClickToZoom( detailVisNetwork, graphEl );
+
+        // Subtle ring background.
+        const ringFills = [
+            { r: Math.max( 100, byGroup.pillar.length  * 40 ), fill: 'rgba(231,108,46,0.06)' },
+            { r: Math.max( 180, byGroup.support.length * 28 ), fill: 'rgba(245,200,66,0.05)' },
+            { r: Math.max( 260, byGroup.light.length   * 22 ), fill: 'rgba(144,184,101,0.04)' },
+        ];
+        detailVisNetwork.on( 'beforeDrawing', ctx => {
+            ringFills.forEach( ring => {
+                ctx.beginPath(); ctx.arc( 0, 0, ring.r, 0, Math.PI * 2 );
+                ctx.fillStyle = ring.fill; ctx.fill();
+            } );
+        } );
+
+        detailVisNetwork.once( 'stabilizationIterationsDone', () => {
+            detailVisNetwork.setOptions( { physics: { enabled: false } } );
+            detailVisNetwork.fit( { animation: { duration: 300, easingFunction: 'easeInOutQuad' } } );
+            if ( graphOverlay ) graphOverlay.style.display = 'none';
+            // Store final positions so the modal can reuse the exact same layout.
+            lastGraphPositions = detailVisNetwork.getPositions();
+        } );
+
+        // Legend.
+        if ( graphLegend ) {
+            const present = new Set( rawNodes.map( n => n.class ).filter( c => c !== 'hub' ) );
+            const ORDER   = [ 'pillar', 'support', 'light', 'orphan' ];
+            const LABELS  = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+            graphLegend.innerHTML = ORDER.filter( r => present.has( r ) ).map( role => {
+                const col = ROLE_COLORS[ role ];
+                return `<span class="clusterlane-graph-legend-item">
+                    <span style="width:10px;height:10px;border-radius:50%;background:${col.bg};border:2px solid ${col.border};display:inline-block;flex-shrink:0;box-sizing:border-box;"></span>
+                    ${esc( LABELS[ role ] )}
+                </span>`;
+            } ).join( '' );
+        }
+
+        // Info bar below graph heading.
+        buildGraphInfoBar( data.cluster, data.truncated ? data.truncated.links : articleLinks( rawEdges ), graphInlineInfobar );
+    }
+
+    /* ------------------------------------------------------------------
+       Graph expand modal
+    ------------------------------------------------------------------ */
+
+    let modalVisNetwork = null;
+
+    function openGraphModal() {
+        if ( ! graphModal || ! lastGraphData || ! graphModalCanvas ) return;
+
+        // Title from current cluster label.
+        if ( graphModalTitle ) graphModalTitle.textContent = detailLabel ? detailLabel.textContent : '';
+
+        graphModal.style.display = 'flex';
+        document.body.style.overflow = 'hidden';
+
+        // Destroy previous modal network if any.
+        if ( modalVisNetwork ) { modalVisNetwork.destroy(); modalVisNetwork = null; }
+        graphModalCanvas.innerHTML = '';
+        if ( graphModalRight ) {
+            graphModalRight.innerHTML = ( typeof window.clusterlaneNodePanelEmpty === 'function' ? window.clusterlaneNodePanelEmpty() : '' );
+        }
+
+        // Short delay so modal has painted and canvas has its real size.
+        requestAnimationFrame( () => renderModalGraph( lastGraphData ) );
+    }
+
+    function closeGraphModal() {
+        if ( ! graphModal ) return;
+        graphModal.style.display = 'none';
+        document.body.style.overflow = '';
+        if ( modalVisNetwork ) { modalVisNetwork.destroy(); modalVisNetwork = null; }
+    }
+
+    function renderModalGraph( data ) {
+        if ( ! graphModalCanvas ) return;
+
+        const rawNodes = structuredClone( data.nodes );
+        const rawEdges = data.edges;
+
+        // Assign colours (same as renderClusterGraph).
+        rawNodes.forEach( n => {
+            const col = ROLE_COLORS[ n.class ] || ROLE_COLORS.light;
+            n.color = { background: col.bg, border: col.border,
+                        highlight: { background: col.bg, border: '#1e293b' } };
+            n.font  = { size: 12, color: '#1e293b', face: 'system-ui,sans-serif', vadjust: -26 };
+            n.shape = 'dot';
+            n.size  = n.class === 'hub' ? 24 : 16;
+            n.borderWidth = n.class === 'hub' ? 3 : 1.5;
+            // Whole title and score for the article panel (0.98.199) — the label is cut below
+            // and the plain-text title becomes an HTML tooltip.
+            const plain = typeof n.title === 'string' ? n.title : '';
+            n.full  = ( plain ? plain.split( '\n' )[ 0 ] : String( n.label || '' ) ).replace( / \(category hub\)$/, '' );
+            const sm = plain.match( /Score:\s*(\d+)/ );
+            n.score = sm ? parseInt( sm[ 1 ], 10 ) : null;
+            if ( n.label ) n.label = graphLabel( n );
+            // Replace plain-string title with a styled HTML element.
+            n.title = buildNodeTooltip( n );
+        } );
+
+        // Reuse exact positions from inline graph if available — identical layout,
+        // just scaled to the larger canvas via fit(). Fall back to ring layout.
+        if ( lastGraphPositions ) {
+            rawNodes.forEach( n => {
+                const pos = lastGraphPositions[ n.id ];
+                if ( pos ) { n.x = pos.x; n.y = pos.y; }
+                if ( n.class === 'hub' ) n.fixed = { x: true, y: true };
+            } );
+        } else {
+            const byGroup = { pillar: [], support: [], light: [], orphan: [] };
+            rawNodes.forEach( n => {
+                if ( n.class === 'hub' ) { n.x = 0; n.y = 0; n.fixed = { x: true, y: true }; return; }
+                const g = byGroup[ n.class ] ? n.class : 'light';
+                byGroup[ g ].push( n );
+            } );
+            const radii = { pillar: 120, support: 220, light: 320, orphan: 420 };
+            Object.entries( byGroup ).forEach( ( [ role, group ] ) => {
+                const r = Math.max( radii[ role ], group.length * ( role === 'pillar' ? 45 : role === 'support' ? 32 : 26 ) );
+                group.forEach( ( n, i ) => {
+                    const angle = -Math.PI / 2 + ( 2 * Math.PI * i ) / Math.max( 1, group.length );
+                    n.x = Math.cos( angle ) * r;
+                    n.y = Math.sin( angle ) * r * 0.82;
+                } );
+            } );
+        }
+
+        const originalColors = {};
+        rawNodes.forEach( n => { originalColors[ n.id ] = n.color; } );
+
+        const nodes = new vis.DataSet( rawNodes );
+        const edges = new vis.DataSet( rawEdges );
+
+        // Compute ring fills from actual node positions for consistent backgrounds.
+        const byGroupForRings = { pillar: [], support: [], light: [], orphan: [] };
+        rawNodes.forEach( n => {
+            if ( n.class === 'hub' ) return;
+            const g = byGroupForRings[ n.class ] ? n.class : 'light';
+            byGroupForRings[ g ].push( n );
+        } );
+
+        modalVisNetwork = new vis.Network( graphModalCanvas, { nodes, edges }, {
+            layout:      { improvedLayout: false, randomSeed: 7 }, // same picture on every load (0.98.182)
+            nodes:       { shape: 'dot', size: 16, borderWidth: 1.5,
+                           font: { size: 12, color: '#333', vadjust: -26, face: 'system-ui,sans-serif' } },
+            edges:       { arrows: { to: { enabled: true, scaleFactor: 0.8 } },
+                           smooth: { type: 'curvedCW', roundness: 0.22 },
+                           width: 1.5, color: '#b0b7c3' },
+            // Physics off from the start when we have saved positions — no re-layout.
+            physics:     lastGraphPositions
+                             ? { enabled: false }
+                             : { enabled: true, minVelocity: 0.75,
+                                 stabilization: { iterations: 220 },
+                                 barnesHut: { gravitationalConstant: -320, centralGravity: 0.12,
+                                              springLength: 180, springConstant: 0.04,
+                                              damping: 0.72, avoidOverlap: 1.1 } },
+            interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true, tooltipDelay: 80 },
+        } );
+
+        // Ring background — derive radii from byGroupForRings counts, same formula as inline.
+        const ringFills = [
+            { r: Math.max( 100, byGroupForRings.pillar.length  * 40 ), fill: 'rgba(231,108,46,0.06)' },
+            { r: Math.max( 180, byGroupForRings.support.length * 28 ), fill: 'rgba(245,200,66,0.05)' },
+            { r: Math.max( 260, byGroupForRings.light.length   * 22 ), fill: 'rgba(144,184,101,0.04)' },
+        ];
+        modalVisNetwork.on( 'beforeDrawing', ctx => {
+            ringFills.forEach( ring => {
+                ctx.beginPath(); ctx.arc( 0, 0, ring.r, 0, Math.PI * 2 );
+                ctx.fillStyle = ring.fill; ctx.fill();
+            } );
+        } );
+
+        const afterReady = () => {
+            modalVisNetwork.fit( { animation: { duration: 400, easingFunction: 'easeInOutQuad' } } );
+        };
+        if ( lastGraphPositions ) {
+            requestAnimationFrame( afterReady );
+        } else {
+            modalVisNetwork.once( 'stabilizationIterationsDone', () => {
+                modalVisNetwork.setOptions( { physics: { enabled: false } } );
+                afterReady();
+            } );
+        }
+
+        // Node-click → right panel (same as Dashboard expand).
+        function resetModalHighlight() {
+            nodes.forEach( n => nodes.update( { id: n.id, hidden: false, color: originalColors[ n.id ],
+                font: { size: 12, color: '#333' }, borderWidth: 1.5 } ) );
+            const eu = [];
+            edges.forEach( e => { const w = e.weight || 1; eu.push( { id: e.id, hidden: false, width: Math.min( w, 5 ), color: w > 3 ? '#1e88e5' : '#b0b7c3' } ); } );
+            edges.update( eu );
+            if ( graphModalRight ) graphModalRight.innerHTML = ( typeof window.clusterlaneNodePanelEmpty === 'function' ? window.clusterlaneNodePanelEmpty() : '' );
+        }
+
+        function selectModalNode( sel ) {
+            const neighbors = modalVisNetwork.getConnectedNodes( sel );
+            const connEdges = modalVisNetwork.getConnectedEdges( sel );
+
+            nodes.forEach( n => {
+                const active = ( n.id === sel || neighbors.includes( n.id ) );
+                nodes.update( { id: n.id,
+                    color: active ? originalColors[ n.id ] : { background: 'rgba(200,200,200,0.15)', border: 'rgba(180,180,180,0.3)' },
+                    font:  active ? { size: 13, color: '#000' } : { color: 'rgba(0,0,0,0.25)' },
+                    borderWidth: active ? 3 : 1 } );
+            } );
+            edges.forEach( e => edges.update( { id: e.id, hidden: ! connEdges.includes( e.id ) } ) );
+
+            if ( ! graphModalRight || typeof window.clusterlaneNodePanel !== 'function' ) return;
+            window.clusterlaneNodePanel( graphModalRight, {
+                node: rawNodes.find( n => n.id === sel ),
+                nodes: rawNodes,
+                edges: rawEdges,
+                full:  n => n.full || String( n.label || '' ),
+                short: n => truncTitle( n.full || String( n.label || '' ) ),
+                inspectorUrl: clusterlaneData.inspectorUrl || '',
+                fromKey: currentKey || '',
+                fromLabel: currentLabel || '',
+                onPick: id => {
+                    modalVisNetwork.selectNodes( [ id ] );
+                    modalVisNetwork.focus( id, { scale: Math.max( modalVisNetwork.getScale(), 0.6 ), animation: { duration: 350, easingFunction: 'easeInOutQuad' } } );
+                    selectModalNode( id );
+                },
+            } );
+        }
+
+        modalVisNetwork.on( 'click', params => {
+            if ( ! params.nodes.length ) { resetModalHighlight(); return; }
+            selectModalNode( params.nodes[ 0 ] );
+        } );
+
+        modalVisNetwork.on( 'doubleClick', params => {
+            if ( params.nodes.length ) {
+                const node = rawNodes.find( n => n.id === params.nodes[ 0 ] );
+                if ( node && node.url ) window.open( node.url, '_blank' );
+            } else { resetModalHighlight(); }
+        } );
+
+        // Legend.
+        if ( graphModalLegend ) {
+            const present = new Set( rawNodes.map( n => n.class ).filter( c => c !== 'hub' ) );
+            const LABELS  = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+            graphModalLegend.innerHTML = [ 'pillar', 'support', 'light', 'orphan' ].filter( r => present.has( r ) ).map( role => {
+                const col = ROLE_COLORS[ role ];
+                return `<span class="clusterlane-graph-legend-item">
+                    <span style="width:10px;height:10px;border-radius:50%;background:${col.bg};border:2px solid ${col.border};display:inline-block;flex-shrink:0;box-sizing:border-box;"></span>
+                    ${esc( LABELS[ role ] )}
+                </span>`;
+            } ).join( '' );
+        }
+
+        // Info bar in modal header.
+        buildGraphInfoBar( data.cluster, data.truncated ? data.truncated.links : articleLinks( rawEdges ), graphModalInfobar );
+    }
+
+    // Expand-knapp.
+    if ( graphExpandBtn ) {
+        graphExpandBtn.addEventListener( 'click', openGraphModal );
+    }
+    // Stäng-knapp.
+    if ( graphModalClose ) {
+        graphModalClose.addEventListener( 'click', closeGraphModal );
+    }
+    // Klick på overlay stänger.
+    if ( graphModal ) {
+        graphModal.addEventListener( 'click', e => { if ( e.target === graphModal ) closeGraphModal(); } );
+    }
+    // Escape stänger.
+    document.addEventListener( 'keydown', e => { if ( e.key === 'Escape' && graphModal && graphModal.style.display !== 'none' ) closeGraphModal(); } );
+
+    /* ------------------------------------------------------------------
+       Improve this cluster — Gap Analysis (Fas 1)
+    ------------------------------------------------------------------ */
+
+    const improveWrap  = document.getElementById( 'clusterlane-ci-improve' );
+    const improveSummary = document.getElementById( 'clusterlane-improve-summary' );
+    // What to do first — filled in as each block learns its numbers.
+    const improveState = { orphan: null, light: null, tms: null, gaps: null };
+    function resetImproveSummary() { improveState.orphan = improveState.light = improveState.tms = improveState.gaps = null; renderImproveSummary(); }
+    function renderImproveSummary() {
+        if ( ! improveSummary ) return;
+        const st = improveState, bits = [];
+        if ( st.tms !== null )    bits.push( `<button type="button" data-open="tms" class="${ st.tms ? 'is-todo' : '' }">${ st.tms } unlinked mention${ st.tms === 1 ? '' : 's' }</button>` );
+        if ( st.orphan !== null ) bits.push( `<button type="button" data-open="ailink" class="${ st.orphan ? 'is-todo' : '' }"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span>${ st.orphan } orphan${ st.orphan === 1 ? '' : 's' }${ st.light ? ' · ' + st.light + ' light' : '' } need${ st.orphan + st.light === 1 ? 's' : '' } links</button>` );
+        bits.push( st.gaps === null ? '<button type="button" data-open="gap">Missing content: not checked yet</button>'
+            : st.gaps === 'pro' ? '<button type="button" data-open="gap">Missing content: Pro (see example)</button>'
+            : `<button type="button" data-open="gap">${ st.gaps } missing article${ st.gaps === 1 ? '' : 's' }</button>` );
+        let first = '';
+        if ( st.tms )                            first = 'Start with the quick wins — ' + st.tms + ' mention' + ( st.tms === 1 ? '' : 's' ) + ' can be linked in one click.';
+        else if ( st.orphan )                    first = 'Start by linking the ' + st.orphan + ' orphan' + ( st.orphan === 1 ? '' : 's' ) + ' — no other article points to ' + ( st.orphan === 1 ? 'it' : 'them' ) + ' yet.';
+        else if ( st.light )                     first = 'Give the ' + st.light + ' lightly linked article' + ( st.light === 1 ? '' : 's' ) + ' another link or two.';
+        else if ( st.gaps && st.gaps !== 'pro' ) first = 'Links look good — consider the missing content.';
+        else if ( st.tms === 0 && st.orphan === 0 ) first = 'This cluster’s links are in good shape.';
+        improveSummary.innerHTML = ( first ? `<p class="clusterlane-improve-first">${ clusterlaneGlyph( 'target' ) }${ esc( first ) }</p>` : '' ) + `<div class="clusterlane-improve-chips">${ bits.join( '' ) }</div>`;
+    }
+    // Scroll to a part of the open cluster: improve | gap | pillar (members table).
+    function focusDetail( what ) {
+        let el = null;
+        if ( what === 'gap' ) { openImproveBlock( 'gap', true ); el = document.getElementById( 'clusterlane-improve-gap' ); }
+        else if ( what === 'split' ) { openImproveBlock( 'split', true ); el = document.getElementById( 'clusterlane-improve-split' ); }
+        else if ( what === 'pillar' ) el = document.getElementById( 'clusterlane-ci-detail-members' );
+        else el = document.getElementById( 'clusterlane-ci-improve' );
+        if ( ! el || el.offsetParent === null ) return;
+        if ( what !== 'gap' ) window.scrollTo( { top: el.getBoundingClientRect().top + window.scrollY - 170, behavior: 'smooth' } );
+        el.classList.remove( 'clusterlane-focus-flash' ); void el.offsetWidth; el.classList.add( 'clusterlane-focus-flash' );
+    }
+    /** Reopen a cluster with fresh data (after its pillar changed elsewhere), optionally at a block. */
+    window.clusterlaneReloadCluster = async function ( key, focus ) {
+        if ( currentKey === key ) currentKey = null;
+        await loadDetail( key );
+        if ( focus ) setTimeout( () => focusDetail( focus ), 350 );
+    };
+    function openImproveBlock( id, scroll ) {
+        const hdr = document.querySelector( '#clusterlane-improve-' + id + ' .clusterlane-improve-block-header' );
+        const body = document.getElementById( 'clusterlane-improve-' + id + '-body' );
+        if ( hdr && body && ! hdr.classList.contains( 'is-open' ) ) { hdr.classList.add( 'is-open' ); body.classList.add( 'is-open' ); }
+        if ( scroll && hdr ) { const top = hdr.getBoundingClientRect().top + window.scrollY - 180; window.scrollTo( { top, behavior: 'smooth' } ); }
+    }
+    if ( improveSummary ) improveSummary.addEventListener( 'click', e => { const b = e.target.closest( '[data-open]' ); if ( b ) openImproveBlock( b.dataset.open, true ); } );
+    document.querySelectorAll( '.clusterlane-improve-block-header[role="button"]' ).forEach( h => h.addEventListener( 'keydown', e => {
+        if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault(); h.click(); }
+    } ) );
+    document.querySelectorAll( '.clusterlane-improve-block-header' ).forEach( h => {
+        const sync = () => h.setAttribute( 'aria-expanded', h.classList.contains( 'is-open' ) ? 'true' : 'false' );
+        new MutationObserver( sync ).observe( h, { attributes: true, attributeFilter: [ 'class' ] } ); sync();
+    } );
+    const gapBlock     = document.getElementById( 'clusterlane-improve-gap' );
+    const gapBody      = document.getElementById( 'clusterlane-improve-gap-body' );
+    const gapCount     = document.getElementById( 'clusterlane-improve-gap-count' );
+    const gapHeader    = gapBlock ? gapBlock.querySelector( '.clusterlane-improve-block-header' ) : null;
+
+    // Toggle expand/collapse on header click.
+    if ( gapHeader ) {
+        gapHeader.addEventListener( 'click', () => {
+            const isOpen = gapHeader.classList.toggle( 'is-open' );
+            gapBody.classList.toggle( 'is-open', isOpen );
+        } );
+    }
+
+    function renderGapEmptyState() {
+        if ( ! gapBody ) return;
+        gapBody.innerHTML = `
+            <div class="clusterlane-improve-cta">
+                <p class="clusterlane-improve-cta-text">Let AI read this cluster and suggest articles it is missing — each with a reason, and a brief or draft on request.</p>
+                <button class="clusterlane-tm-btn clusterlane-tm-btn--apply clusterlane-gap-analyze-btn">${ clusterlaneGlyph( 'sparkle' ) }Suggest missing articles</button>
+            </div>`;
+        const btn = gapBody.querySelector( '.clusterlane-gap-analyze-btn' );
+        if ( btn ) {
+            btn.addEventListener( 'click', () => {
+                if ( currentKey ) loadGapAnalysis( currentKey, true );
+            } );
+        }
+    }
+
+    function renderGapResults( data ) {
+        if ( ! gapBody ) return;
+        const suggs = data.suggestions || [];
+        if ( ! suggs.length ) {
+            gapBody.innerHTML = '<p class="clusterlane-improve-loading">No gaps found — cluster looks complete.</p>';
+            return;
+        }
+
+        // Free example (0.98.185): the demo site's numbers never reach this cluster's
+        // summary or count pill — they would read as findings about the user's own cluster.
+        const isExample = ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() );
+        if ( isExample ) { improveState.gaps = 'pro'; renderImproveSummary(); }
+        else { improveState.gaps = suggs.length; renderImproveSummary(); }
+        // Update count pill.
+        if ( gapCount ) {
+            gapCount.textContent = isExample ? 'PRO · example' : suggs.length + ' idea' + ( suggs.length !== 1 ? 's' : '' );
+            gapCount.className   = 'clusterlane-improve-count ' + ( isExample ? 'clusterlane-improve-count--pro' : 'clusterlane-improve-count--gap' );
+            gapCount.style.display = '';
+        }
+
+        const cached = data.cached
+            ? `<span style="font-size:10px;color:#94a3b8;margin-left:6px;">cached ${ data.generated_at ? '· ' + timeAgo( data.generated_at ) : '' }</span>`
+            : '';
+
+        const rows = suggs.map( s => `
+            <div class="clusterlane-gap-item">
+                <div class="clusterlane-gap-item-text">
+                    ${ esc( s.title ) }
+                    ${ s.rationale ? `<small>${ esc( s.rationale ) }</small>` : '' }
+                </div>
+                <div class="clusterlane-gap-item-actions">
+                    <button class="clusterlane-tm-btn clusterlane-gap-brief-btn"
+                            title="AI writes a content brief (headings, keyword, links to add) and copies it to your clipboard — paste it for a writer or your AI tool."
+                            data-title="${ esc( s.title ) }"
+                            data-rationale="${ esc( s.rationale || '' ) }">
+                        ${ clusterlaneGlyph( 'sparkle' ) }Copy brief
+                    </button>
+                    <button class="clusterlane-tm-btn clusterlane-tm-btn--apply clusterlane-gap-draft-btn"
+                            title="Creates an unpublished WordPress draft with the brief inside — nothing is published."
+                            data-title="${ esc( s.title ) }"
+                            data-rationale="${ esc( s.rationale || '' ) }">
+                        ${ clusterlaneGlyph( 'sparkle' ) }Create draft
+                    </button>
+                </div>
+            </div>` ).join( '' );
+
+        gapBody.innerHTML = `<p class="clusterlane-gap-help"><strong>Copy brief</strong> — AI writes an outline and copies it for you. <strong>Create draft</strong> — an unpublished draft with the brief inside. ${ cached }</p>` + rows;
+
+        // Brief / Draft actions (Pro): clusterlane-pro.js
+        if ( window.clusterlanePro && window.clusterlanePro.ciGapBind ) window.clusterlanePro.ciGapBind( { REST, clusterlaneData, gapBody, get_currentKey: () => currentKey } );
+    }
+
+    async function loadGapAnalysis( key, force = false ) {
+        if ( ! improveWrap || ! gapBody ) return;
+        improveWrap.style.display = '';
+
+        // Free edition: example of Pro's gap analysis (demo site) — no AI call.
+        if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+            const ex = clusterlaneProExample( 'gap' );
+            if ( ex ) {
+                renderGapResults( ex );
+                gapBody.insertAdjacentHTML( 'afterbegin', clusterlaneProBanner( 'Gap Analysis' ) );
+                clusterlaneFreezeExample( gapBody );
+            }
+            return;
+        }
+        // Live AI (Pro): admin/js/pro__premium_only/clusterlane-pro.js
+        return window.clusterlanePro.ciGapLoad( { key, force, REST, clusterlaneData, esc, gapBody, gapHeader, renderGapEmptyState, renderGapResults } );
+    }
+
+    /* ------------------------------------------------------------------
+       Improve this cluster — TMS (Fas 2)
+    ------------------------------------------------------------------ */
+
+    const tmsBlock  = document.getElementById( 'clusterlane-improve-tms' );
+    const tmsBody   = document.getElementById( 'clusterlane-improve-tms-body' );
+    const tmsCount  = document.getElementById( 'clusterlane-improve-tms-count' );
+    const tmsHeader = tmsBlock ? tmsBlock.querySelector( '.clusterlane-improve-block-header' ) : null;
+
+    if ( tmsHeader ) {
+        tmsHeader.addEventListener( 'click', () => {
+            const isOpen = tmsHeader.classList.toggle( 'is-open' );
+            tmsBody.classList.toggle( 'is-open', isOpen );
+        } );
+    }
+
+    // Quick wins: the same rows as Text Match — the sentence with exactly the
+    // words that get linked, the target's role (orphans first), Apply / Skip,
+    // and a green "Linked · Undo" state.
+    let tmsItems = [];
+    const TMS_SHOW = 8;
+    function tmsSentence( m ) {
+        const cp = Array.from( String( m.snippet || '' ) );
+        const at = parseInt( m.hit_at, 10 ), len = parseInt( m.hit_len, 10 );
+        if ( ! ( at >= 0 && len > 0 && at + len <= cp.length ) ) return esc( m.snippet || m.match_text );
+        return esc( cp.slice( 0, at ).join( '' ) ) + '<span class="clusterlane-tm-hit">' + esc( cp.slice( at, at + len ).join( '' ) ) + '</span>' + esc( cp.slice( at + len ).join( '' ) );
+    }
+    function renderTmsResults( allMatches, clusterKey ) {
+        if ( ! tmsBody ) return;
+        if ( allMatches ) {
+            tmsItems = allMatches.map( m => Object.assign( { status: 'open' }, m ) );
+            tmsItems.sort( ( a, b ) => ( ( a.target_role === 'orphan' ? 0 : 1 ) - ( b.target_role === 'orphan' ? 0 : 1 ) ) || ( ( a.target_inbound || 0 ) - ( b.target_inbound || 0 ) ) );
+        }
+        const open = tmsItems.filter( m => m.status === 'open' || m.status === 'busy' );
+        improveState.tms = open.length; renderImproveSummary();
+        if ( tmsCount ) {
+            tmsCount.textContent = open.length + ' mention' + ( open.length !== 1 ? 's' : '' );
+            tmsCount.className = 'clusterlane-improve-count clusterlane-improve-count--tms';
+            tmsCount.style.display = open.length ? '' : 'none';
+        }
+        const visible = tmsItems.filter( m => m.status !== 'skipped' && m.status !== 'done' ).slice( 0, TMS_SHOW );
+        if ( ! visible.length ) {
+            tmsBody.innerHTML = '<p class="clusterlane-improve-loading">' + ( tmsItems.length ? 'All mentions in this cluster are handled.' : 'No unlinked mentions — articles in this cluster already link to the titles they mention.' ) + '</p>';
+            return;
+        }
+        const ROLE = { pillar: 'Pillar', support: 'Support', light: 'Light', orphan: 'Orphan' };
+        // Roles without the "add links" permission can review and skip, not link.
+        const CAN_LINK = typeof window.clusterlaneCan !== 'function' || window.clusterlaneCan( 'links' );
+        const rows = visible.map( m => {
+            const i = tmsItems.indexOf( m );
+            if ( m.status === 'linked' ) {
+                return `<div class="clusterlane-tm-row clusterlane-tm-row--linked"><div class="clusterlane-tm-row-main"><p class="clusterlane-tm-done">${ clusterlaneGlyph( 'check' ) }Linked in <strong>${ esc( m.source_title ) }</strong> → <strong>${ esc( m.target_title ) }</strong></p></div>
+                    ${ CAN_LINK ? `<div class="clusterlane-tm-row-actions"><button type="button" class="clusterlane-tm-btn clusterlane-ci-tms-undo" data-i="${ i }">Undo</button></div>` : '' }</div>`;
+            }
+            const role = ROLE[ m.target_role ] ? ` <span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--${ m.target_role }"></span>${ ROLE[ m.target_role ] }</span>` : '';
+            const busy = m.status === 'busy';
+            return `<div class="clusterlane-tm-row${ m.target_role === 'orphan' ? ' clusterlane-tm-row--orphan' : '' }">
+                <div class="clusterlane-tm-row-main">
+                    <p class="clusterlane-tm-sentence"><span class="clusterlane-tm-in">${ clusterlaneThumb( m.source_thumb || '', m.source_title, '', 'xs' ) }In ${ esc( truncTitle( m.source_title ) ) }:</span> ${ tmsSentence( m ) }</p>
+                    <p class="clusterlane-tm-target">${ clusterlaneGlyph( 'arrow' ) }link to ${ clusterlaneThumb( m.target_thumb || '', m.target_title, m.target_role, 'xs' ) }<strong>${ esc( truncTitle( m.target_title ) ) }</strong>${ role }</p>
+                </div>
+                <div class="clusterlane-tm-row-actions">
+                    ${ CAN_LINK ? `<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply clusterlane-ci-tms-apply" data-i="${ i }"${ busy ? ' disabled' : '' }>${ busy ? 'Linking…' : 'Apply' }</button>` : '' }
+                    <button type="button" class="clusterlane-tm-btn clusterlane-ci-tms-skip" data-i="${ i }"${ busy ? ' disabled' : '' }>Skip</button>
+                </div></div>`;
+        } ).join( '' );
+        const more = open.length - visible.filter( m => m.status !== 'linked' ).length;
+        tmsBody.innerHTML = ( CAN_LINK ? '' : window.clusterlaneNoRightNote( 'links' ) )
+            + `<div class="clusterlane-tm-group clusterlane-ci-tms-list">${ rows }</div>`
+            + ( open.length > 1 && CAN_LINK ? `<p class="clusterlane-ci-tms-foot"><button type="button" class="clusterlane-tm-linkbtn clusterlane-ci-tms-all">Apply all ${ open.length }</button>${ more > 0 ? ` · <a href="admin.php?page=clusterlane-text-match" target="_blank">${ more } more in Text Match</a>` : '' }</p>` : '' );
+    }
+    async function tmsCall( path, body ) {
+        const r = await fetch( REST + path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': clusterlaneData.nonce }, body: JSON.stringify( body ) } );
+        const d = await r.json().catch( () => ( {} ) );
+        if ( ! r.ok || d.success === false ) throw new Error( d.error || ( 'HTTP ' + r.status ) );
+        return d;
+    }
+    async function tmsApply( m ) {
+        m.status = 'busy'; renderTmsResults();
+        try {
+            const d = await tmsCall( 'tms/apply', { source_id: parseInt( m.source_id, 10 ), match_text: m.match_text, target_url: m.target_url } );
+            m.status = 'linked';
+            if ( d.target ) tmsItems.forEach( x => { if ( +x.target_id === d.target.id ) { x.target_role = d.target.role; x.target_inbound = d.target.inbound; } } );
+            setTimeout( () => { if ( m.status === 'linked' ) { m.status = 'done'; renderTmsResults(); } }, 8000 );
+            if ( currentKey ) loadClusterGraph( currentKey );
+        } catch ( err ) { m.status = 'open'; alert( err.message ); }
+        renderTmsResults();
+    }
+    if ( tmsBody ) tmsBody.addEventListener( 'click', async e => {
+        const b = e.target.closest( 'button' ); if ( ! b || b.disabled ) return;
+        const m = tmsItems[ parseInt( b.dataset.i, 10 ) ];
+        if ( b.classList.contains( 'clusterlane-ci-tms-apply' ) && m ) { tmsApply( m ); return; }
+        if ( b.classList.contains( 'clusterlane-ci-tms-skip' ) && m ) {
+            m.status = 'skipped'; renderTmsResults();
+            tmsCall( 'tms/skip', { source_id: parseInt( m.source_id, 10 ), target_id: parseInt( m.target_id, 10 ) } ).catch( () => {} );
+            return;
+        }
+        if ( b.classList.contains( 'clusterlane-ci-tms-undo' ) && m ) {
+            b.disabled = true; b.textContent = 'Undoing…';
+            try { const d = await tmsCall( 'tms/unlink', { source_id: parseInt( m.source_id, 10 ), match_text: m.match_text, target_url: m.target_url } ); m.status = 'open';
+                  if ( d.target ) tmsItems.forEach( x => { if ( +x.target_id === d.target.id ) { x.target_role = d.target.role; x.target_inbound = d.target.inbound; } } );
+                  if ( currentKey ) loadClusterGraph( currentKey ); }
+            catch ( err ) { alert( err.message ); }
+            renderTmsResults(); return;
+        }
+        if ( b.classList.contains( 'clusterlane-ci-tms-all' ) ) {
+            b.disabled = true;
+            for ( const x of tmsItems.filter( y => y.status === 'open' ) ) { await tmsApply( x ); }
+        }
+    } );
+
+    // Big clusters (0.98.169): searching every article for mentions takes minutes on a
+    // 1,000+ article cluster and kept the server busy each time the cluster was opened —
+    // above this size it starts on a click instead.
+    const TMS_AUTO_MAX = 1000; // 0.98.170: Text Match is ~13× faster, so bigger clusters can search on their own
+    async function loadTmsMatches( key, memberPostIds, force ) {
+        if ( ! tmsBody ) return;
+        const n = memberPostIds ? memberPostIds.length : 0;
+        if ( n > TMS_AUTO_MAX && ! force ) {
+            tmsBody.innerHTML = `<div class="clusterlane-improve-loading clusterlane-tms-big">This cluster has ${ n.toLocaleString() } articles, so finding mentions takes a few minutes. `
+                + `<button type="button" class="clusterlane-tm-btn" data-tms-start>${ clusterlaneGlyph( 'search' ) }Find mentions</button></div>`;
+            const go = tmsBody.querySelector( '[data-tms-start]' );
+            if ( go ) go.addEventListener( 'click', () => loadTmsMatches( key, memberPostIds, true ) );
+            return;
+        }
+        tmsBody.innerHTML = '<div class="clusterlane-improve-loading">Scanning for matches…</div>';
+
+        // Auto-open.
+        if ( tmsHeader && ! tmsHeader.classList.contains( 'is-open' ) ) {
+            tmsHeader.classList.add( 'is-open' );
+            tmsBody.classList.add( 'is-open' );
+        }
+
+        const allMatches = [];
+
+        // Server-side scan of the cluster's own articles, in ~4 s chunks
+        // (was one request per article — 870 requests for a big cluster).
+        let cursor = 0;
+        while ( cursor !== null ) {
+            try {
+                const res = await fetch( REST + 'tms/scan-cluster', {
+                    method:  'POST',
+                    headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE },
+                    body:    JSON.stringify( { key, cursor } ),
+                } );
+                if ( ! res.ok ) break;
+                const data = await res.json();
+                ( data.matches || [] ).forEach( m => allMatches.push( m ) );
+                cursor = data.next_cursor;
+                if ( cursor !== null && key === currentKey ) {
+                    tmsBody.innerHTML = `<div class="clusterlane-improve-loading">Scanning for matches… ${ data.scanned } / ${ data.total } articles</div>`;
+                }
+                if ( key !== currentKey ) return; // another cluster was opened
+            } catch { break; }
+        }
+        if ( memberPostIds && ! memberPostIds.length ) { /* nothing to scan */ }
+
+        renderTmsResults( allMatches, key );
+    }
+
+    /* ------------------------------------------------------------------
+       Improve this cluster — AI Link Suggestions (Fas 3)
+    ------------------------------------------------------------------ */
+
+    const ailinkBlock  = document.getElementById( 'clusterlane-improve-ailink' );
+    const ailinkBody   = document.getElementById( 'clusterlane-improve-ailink-body' );
+    const ailinkCount  = document.getElementById( 'clusterlane-improve-ailink-count' );
+    const ailinkHeader = ailinkBlock ? ailinkBlock.querySelector( '.clusterlane-improve-block-header' ) : null;
+
+    if ( ailinkHeader ) {
+        ailinkHeader.addEventListener( 'click', () => {
+            const isOpen = ailinkHeader.classList.toggle( 'is-open' );
+            ailinkBody.classList.toggle( 'is-open', isOpen );
+        } );
+    }
+
+    async function loadAiLinkSuggestions( key, members ) {
+        if ( ! ailinkBody ) return;
+
+        // Free edition: example of Pro's link suggestions (demo site) — no AI call.
+        if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+            const ex = clusterlaneProExample( 'link_suggest' );
+            if ( ex ) {
+                const variants = ( ex.suggestions || [] ).map( ( v, i ) => `
+                    <div class="clusterlane-pro-example-variant">
+                        <span class="clusterlane-pro-example-variant-head">Option ${ i + 1 } <span class="clusterlane-pro-example-pos">${ esc( v.position ) }</span></span>
+                        <p>${ esc( v.context ).replace( '[LINK]', '<a href="#" tabindex="-1" onclick="return false;">' + esc( v.anchor_text ) + '</a>' ) }</p>
+                    </div>` ).join( '' );
+                ailinkBody.innerHTML = clusterlaneProBanner( 'Link Suggestions' )
+                    + `<div class="clusterlane-pro-example-pair">Link <strong>${ esc( ex.source_title ) }</strong> <span class="clusterlane-ailink-arrow">→</span> <strong>${ esc( ex.target_title ) }</strong></div>`
+                    + `<div class="clusterlane-pro-example-variants">${ variants }</div>`;
+                clusterlaneFreezeExample( ailinkBody );
+            }
+            return;
+        }
+        // Live AI (Pro): admin/js/pro__premium_only/clusterlane-pro.js
+        return window.clusterlanePro.ciAiLinks( { key, members, NONCE, REST, ailinkBody, ailinkCount, ailinkHeader, esc, loadClusterGraph, truncTitle, get_currentKey: () => currentKey, get_currentLabel: () => currentLabel, get_lastGraphData: () => lastGraphData } );
+    }
+
+    /* ------------------------------------------------------------------
+       AI Analysis
+    ------------------------------------------------------------------ */
+
+    async function loadAiAnalysis( key, force = false ) {
+        if ( ! aiResult || ! aiBtn ) return;
+
+        // Free edition: example of Pro's cluster analysis (demo site) — no AI call.
+        if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+            const ex = clusterlaneProExample( 'cluster_analysis' );
+            if ( ex ) {
+                aiResult.style.display = '';
+                renderAiResult( ex, key );
+                aiResult.insertAdjacentHTML( 'afterbegin', clusterlaneProBanner( 'Cluster Analysis' ) );
+                clusterlaneFreezeExample( aiResult );
+            }
+            return;
+        }
+        // Live AI (Pro): admin/js/pro__premium_only/clusterlane-pro.js
+        return window.clusterlanePro.ciAiAnalysis( { key, force, NONCE, REST, aiBtn, aiResult, esc, loadAiAnalysis, renderAiResult, get_currentKey: () => currentKey } );
+    }
+
+    function renderAiResult( data, key ) {
+        if ( ! aiResult ) return;
+
+        const cached = data.cached
+            ? `<span class="clusterlane-ai-badge" style="font-size:10px;padding:2px 7px;vertical-align:middle;margin-left:6px;">cached</span>${ data.generated_at ? `<span style="font-size:10px;color:rgba(255,255,255,.4);margin-left:6px;">${esc( timeAgo( data.generated_at ) )}</span>` : '' }`
+            : '';
+
+        const staleBanner = data.stale
+            ? `<div class="clusterlane-cluster-ai-stale-banner">
+                   ${ clusterlaneGlyph( 'alert' ) }<strong>Cluster data has changed</strong> since this analysis was generated — regenerate for updated insights.
+                   <button class="clusterlane-cluster-ai-stale-regen" onclick="this.closest('.clusterlane-cluster-ai-stale-banner').style.display='none';">
+                       ${ clusterlaneGlyph( 'sparkle' ) }Regenerate now
+                   </button>
+               </div>`
+            : '';
+
+        const strengthsHtml = ( data.strengths || [] ).map( s =>
+            `<li style="margin-bottom:4px;color:#166534;">${esc( s )}</li>`
+        ).join('');
+
+        const improvementsHtml = ( data.improvements || [] ).map( imp => `
+            <div style="margin-bottom:10px;">
+                <strong style="font-size:12px;color:#1e293b;">${esc( imp.title )}</strong>
+                <p style="margin:2px 0 0;font-size:12px;color:#475569;line-height:1.5;">${esc( imp.description )}</p>
+            </div>
+        ` ).join('');
+
+        const gapsHtml = ( data.content_gaps || [] ).map( g =>
+            `<li style="margin-bottom:4px;color:#1d4ed8;">${esc( g )}</li>`
+        ).join('');
+
+        aiResult.innerHTML = `
+            <div class="clusterlane-cluster-ai-card">
+                <div class="clusterlane-cluster-ai-card-header">
+                    <span class="clusterlane-ai-badge">${ clusterlaneGlyph( 'sparkle' ) }AI</span>
+                    <span class="clusterlane-cluster-ai-card-title">Cluster Analysis</span>
+                    ${cached}
+                    <span style="margin-left:auto;font-size:10px;color:rgba(255,255,255,.5);">${esc( data.model || '' )}</span>
+                </div>
+                <div class="clusterlane-cluster-ai-card-body">
+                    ${staleBanner}
+                    <p style="margin:0 0 16px;font-size:13px;color:#334155;line-height:1.6;">${esc( data.summary )}</p>
+
+                    <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex-wrap:wrap;">
+                        <div>
+                            <h5 class="clusterlane-cluster-ai-section-heading" style="color:#166534;">${ clusterlaneGlyph( 'check' ) }Strengths</h5>
+                            <ul style="margin:0;padding-left:16px;font-size:12px;line-height:1.6;">${strengthsHtml}</ul>
+                        </div>
+                        <div>
+                            <h5 class="clusterlane-cluster-ai-section-heading" style="color:#1d4ed8;">${ clusterlaneGlyph( 'bulb' ) }Content gaps</h5>
+                            <ul style="margin:0;padding-left:16px;font-size:12px;line-height:1.6;">${gapsHtml}</ul>
+                        </div>
+                    </div>
+
+                    <div style="margin-top:16px;">
+                        <h5 class="clusterlane-cluster-ai-section-heading" style="color:#92400e;">${ clusterlaneGlyph( 'wrench' ) }Improvements</h5>
+                        ${improvementsHtml}
+                    </div>
+                </div>
+            </div>`;
+
+        // Wire up the stale-banner regenerate button (can't use inline onclick with closure).
+        const staleBtn = aiResult.querySelector( '.clusterlane-cluster-ai-stale-regen' );
+        if ( staleBtn ) {
+            staleBtn.addEventListener( 'click', () => loadAiAnalysis( currentKey, true ) );
+        }
+    }
+
+    if ( aiBtn ) {
+        aiBtn.addEventListener( 'click', () => {
+            if ( currentKey ) loadAiAnalysis( currentKey );
+        } );
+    }
+
+    /* ------------------------------------------------------------------
+       Utilities
+    ------------------------------------------------------------------ */
+
+    function esc( str ) {
+        return String( str )
+            .replace( /&/g, '&amp;' )
+            .replace( /</g, '&lt;' )
+            .replace( />/g, '&gt;' )
+            .replace( /"/g, '&quot;' )
+            .replace( /'/g, '&#039;' );
+    }
+
+    function timeAgo( utcStr ) {
+        if ( ! utcStr ) return '';
+        const d    = new Date( utcStr.replace( ' ', 'T' ) + 'Z' );
+        const secs = Math.round( ( Date.now() - d.getTime() ) / 1000 );
+        if ( secs < 60 )    return 'just now';
+        if ( secs < 3600 )  return Math.floor( secs / 60 )   + ' min ago';
+        if ( secs < 86400 ) return Math.floor( secs / 3600 ) + ' h ago';
+        const days = Math.floor( secs / 86400 );
+        return days === 1 ? 'yesterday' : days + ' days ago';
+    }
+
+    // Graph labels (0.98.187): the title display rule, then at most ~24 characters cut at a
+    // word ("Vasa Museum: The Warship…"), never mid-word ("…The Warship Tha…").
+    function graphLabel( n ) {
+        // The server cuts long titles at 28 characters; only then is the full title (first line of the tooltip) needed.
+        const lbl = String( n.label || '' );
+        const full = /…$/.test( lbl ) && typeof n.title === 'string' ? n.title.split( '\n' )[ 0 ] : lbl;
+        const t = truncTitle( full );
+        if ( t.length <= 24 ) return t;
+        const cut = t.slice( 0, 24 ), sp = cut.lastIndexOf( ' ' );
+        // A lone short word after a colon says nothing ("Hallwyl Museum: A…") — stop at the colon.
+        return ( sp > 10 ? cut.slice( 0, sp ) : cut ).replace( /:\s*\S{1,3}$/, '' ).replace( /[\s:,–—-]+$/, '' ) + '…';
+    }
+
+    function truncTitle( str ) {
+        if ( ! str ) return '';
+        // Decode HTML entities (e.g. &amp; → &) before applying truncation.
+        const decoded = str.replace( /&amp;/g, '&' ).replace( /&lt;/g, '<' )
+                           .replace( /&gt;/g, '>' ).replace( /&quot;/g, '"' )
+                           .replace( /&#039;/g, "'" ).replace( /&apos;/g, "'" );
+        const mode = ( clusterlaneData.titleTrunc || 'none' );
+        if ( mode === 'none'  ) return decoded;
+        // A hyphen only counts with spaces around it ("Open-Air" stays whole);
+        // an empty result keeps the full title. Same rules as the Settings preview.
+        let cut = decoded;
+        if ( mode === 'colon' ) cut = decoded.replace( /\s*:.*$/, '' ).trim();
+        if ( mode === 'dash'  ) cut = decoded.replace( /(\s*[\u2013\u2014]|\s+-\s).*$/, '' ).trim();
+        if ( mode === 'pipe'  ) cut = decoded.replace( /\s*\|.*$/, '' ).trim();
+        if ( mode !== 'custom' ) return cut || decoded;
+        if ( mode === 'custom' && clusterlaneData.titleTruncCustom ) {
+            try {
+                const m = clusterlaneData.titleTruncCustom.match( /^\/(.+)\/([gimsuy]*)$/ );
+                if ( m ) return decoded.replace( new RegExp( m[1], m[2] ), '' ).trim() || decoded;
+            } catch(e) {}
+        }
+        return decoded;
+    }
+
+    /* ------------------------------------------------------------------
+       Init
+    ------------------------------------------------------------------ */
+
+    boot();
+
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/cluster-review.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/cluster-review.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/cluster-review.js	(revision 3751027)
@@ -0,0 +1,201 @@
+/* Clusterlane — Cluster Admin → "Review cluster structure" (0.98.141).
+ *
+ * A dialog listing every cluster with the last pillar & split check: which
+ * could be split (and into what), which have no natural main article (and
+ * what to write), which have a better main article, which are one topic.
+ * "Check all" runs the check for clusters never checked or whose articles
+ * changed, one at a time, with the number of AI calls shown first and a Stop
+ * button; saved answers cost nothing. Nothing is changed here — "Review →"
+ * opens the cluster in Cluster Inspector at its Pillar & split check.
+ *
+ * Live data and checks are Pro (window.clusterlanePro.splitOverview / splitLoad);
+ * Free shows the demo example.
+ */
+( function () {
+    'use strict';
+    var dlg  = document.getElementById( 'clusterlane-review' );
+    var open = document.getElementById( 'clusterlane-review-open' );
+    var body = document.getElementById( 'clusterlane-review-body' );
+    if ( ! dlg || ! open || ! body ) return;
+    var R = window.clusterlaneReview || {};
+    var S = { rows: [], min: 6, example: false, filter: 'all', running: false, stop: false, done: 0, todo: 0, loaded: false };
+
+    function esc( s ) { return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' ); }
+    function glyph( n ) { return typeof window.clusterlaneGlyph === 'function' ? window.clusterlaneGlyph( n ) : ''; }
+    function pl( n, one, many ) { return n + ' ' + ( n === 1 ? one : many ); }
+    function live() { return typeof clusterlaneProReady === 'function' && clusterlaneProReady() && window.clusterlanePro && window.clusterlanePro.splitOverview; }
+    function ago( t ) {
+        if ( ! t ) return '';
+        var s = Math.max( 0, ( Date.now() - new Date( String( t ).replace( ' ', 'T' ) + 'Z' ).getTime() ) / 1000 );
+        return s < 90 ? 'just now' : s < 5400 ? Math.round( s / 60 ) + ' min ago' : s < 129600 ? Math.round( s / 3600 ) + ' h ago' : Math.round( s / 86400 ) + ' days ago';
+    }
+
+    open.addEventListener( 'click', function () {
+        if ( typeof dlg.showModal === 'function' ) dlg.showModal(); else dlg.setAttribute( 'open', '' );
+        if ( ! S.loaded ) load();
+    } );
+    dlg.addEventListener( 'click', function ( e ) {
+        if ( e.target === dlg || e.target.closest( '[data-review-close]' ) ) close();
+    } );
+    dlg.addEventListener( 'cancel', function ( e ) { if ( S.running ) { e.preventDefault(); S.stop = true; } } );
+    function close() { if ( S.running ) S.stop = true; if ( dlg.open && typeof dlg.close === 'function' ) dlg.close(); else dlg.removeAttribute( 'open' ); }
+
+    /* ── Data ───────────────────────────────────────────────────────── */
+    function load() {
+        if ( ! live() ) {
+            var ex = typeof clusterlaneProExample === 'function' ? clusterlaneProExample( 'structure_review' ) : null;
+            if ( ! ex ) { body.innerHTML = '<p class="clusterlane-improve-loading">Not available.</p>'; return; }
+            S.example = true; setRows( ex ); return;
+        }
+        body.innerHTML = '<p class="clusterlane-improve-loading">Loading clusters…</p>';
+        window.clusterlanePro.splitOverview( { REST: R.restUrl, nonce: R.nonce,
+            done: function ( d ) { S.loaded = true; setRows( d ); },
+            fail: function ( m ) { body.innerHTML = '<p class="clusterlane-improve-loading clusterlane-split-error">' + esc( m ) + '</p>'; }
+        } );
+    }
+    function rank( r ) {
+        if ( ! r.eligible ) return 9;
+        var s = r.summary;
+        if ( ! s ) return 6;
+        if ( s.groups && s.groups.length ) return 0;
+        if ( s.pillar_idea ) return 1;
+        if ( s.better_pillar ) return 2;
+        return 4;
+    }
+    function setRows( d ) {
+        S.min = d.min || 6;
+        S.rows = ( d.clusters || [] ).slice();
+        // Findings first, biggest first — sorted once, so rows don't jump while checking.
+        S.rows.sort( function ( a, b ) { return rank( a ) - rank( b ) || b.members - a.members; } );
+        render();
+    }
+    function needs( r ) { return r.eligible && ( r.status === 'none' || r.status === 'stale' || r.status === 'error' ); }
+
+    /* ── Render ─────────────────────────────────────────────────────── */
+    function counts() {
+        var el = S.rows.filter( function ( r ) { return r.eligible; } ), c = { el: el.length, checked: 0, split: 0, parts: 0, idea: 0, better: 0, one: 0, fresh: 0, need: 0, stale: 0 };
+        el.forEach( function ( r ) {
+            if ( needs( r ) ) { c.need++; if ( r.status === 'stale' ) c.stale++; }
+            var s = r.summary; if ( ! s ) return;
+            c.checked++;
+            if ( s.groups && s.groups.length ) { c.split++; c.parts += s.groups.length; }
+            if ( s.pillar_idea ) c.idea++;
+            if ( s.better_pillar ) c.better++;
+            if ( ( ! s.groups || ! s.groups.length ) && ! s.pillar_idea && ! s.better_pillar ) c.one++;
+        } );
+        return c;
+    }
+    function render() {
+        var c = counts();
+        var small = S.rows.filter( function ( r ) { return ! r.eligible; } );
+        var summary = c.checked
+            ? [ c.split ? pl( c.split, 'cluster', 'clusters' ) + ' could be split into ' + c.parts + ' smaller ones' : 'none could be split',
+                c.idea ? pl( c.idea, 'has', 'have' ) + ' no natural main article' : '',
+                c.better ? pl( c.better, 'has a better main article among its articles', 'have a better main article among their articles' ) : '',
+                c.one ? pl( c.one, 'is', 'are' ) + ' in good shape' : '' ].filter( Boolean ).join( ' · ' )
+            : 'No cluster checked yet.';
+        var html = ( S.example && typeof clusterlaneProBanner === 'function' ? clusterlaneProBanner( 'Review cluster structure' ) : '' )
+            + '<div class="clusterlane-review-summary"><div><strong>' + esc( c.checked + ' of ' + pl( c.el, 'cluster', 'clusters' ) + ' checked' ) + '</strong><span>' + esc( summary ) + '</span></div>'
+            + '<div class="clusterlane-review-run" data-review-run>' + runHtml( c ) + '</div></div>'
+            + '<div class="clusterlane-review-filters" role="group" aria-label="Show">' + [ [ 'all', 'All', c.el ], [ 'split', 'Could split', c.split ], [ 'pillar', 'Pillar to fix', c.idea + c.better ], [ 'todo', 'Not checked', c.need ] ].map( function ( f ) {
+                return '<button type="button" data-review-filter="' + f[0] + '" aria-pressed="' + ( S.filter === f[0] ) + '"' + ( S.filter === f[0] ? ' class="is-active"' : '' ) + '>' + esc( f[1] ) + ' <em>' + f[2] + '</em></button>';
+            } ).join( '' ) + '</div>'
+            + '<div class="clusterlane-review-list">' + S.rows.filter( function ( r ) { return r.eligible && show( r ); } ).map( rowHtml ).join( '' ) + '</div>'
+            + ( small.length ? '<p class="clusterlane-review-small">' + esc( pl( small.length, 'cluster has', 'clusters have' ) + ' fewer than ' + S.min + ' articles — too small to check: ' + small.map( function ( r ) { return r.label; } ).slice( 0, 12 ).join( ', ' ) + ( small.length > 12 ? '…' : '' ) ) + '</p>' : '' );
+        body.innerHTML = html;
+        if ( S.example ) {
+            clusterlaneFreezeExample( body );
+            body.querySelectorAll( '[data-review-filter]' ).forEach( function ( b ) { b.disabled = false; b.removeAttribute( 'title' ); } ); // looking around is fine
+        }
+    }
+    function show( r ) {
+        var s = r.summary;
+        if ( S.filter === 'split' ) return !! ( s && s.groups && s.groups.length );
+        if ( S.filter === 'pillar' ) return !! ( s && ( s.pillar_idea || s.better_pillar ) );
+        if ( S.filter === 'todo' ) return needs( r );
+        return true;
+    }
+    function runHtml( c ) {
+        if ( S.running ) {
+            var pct = S.todo ? Math.round( S.done / S.todo * 100 ) : 0;
+            return '<div class="clusterlane-review-progress"><span>' + esc( 'Checking ' + ( S.done + 1 ) + ' of ' + S.todo + '…' ) + '</span><div class="clusterlane-split-bar"><span style="width:' + pct + '%;--c:#16223a"></span></div></div>'
+                + '<button type="button" class="clusterlane-tm-btn" data-review-stop>Stop</button>';
+        }
+        if ( ! c.need ) return '<span class="clusterlane-review-alldone">' + glyph( 'check' ) + 'Every cluster is checked and up to date.</span>';
+        var parts = [];
+        if ( c.need - c.stale ) parts.push( ( c.need - c.stale ) + ' not checked' );
+        if ( c.stale ) parts.push( c.stale + ' with changed articles' );
+        return '<span class="clusterlane-review-cost">' + esc( parts.join( ' · ' ) + ' — ' + pl( c.need, 'AI call', 'AI calls' ) + '. Saved checks are free.' ) + '</span>'
+            + '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-review-all>' + glyph( 'sparkle' ) + esc( 'Check ' + ( c.need === c.el ? 'all' : c.need ) ) + '</button>';
+    }
+    function rowHtml( r ) {
+        var s = r.summary, res = '';
+        if ( r.status === 'checking' ) res = '<span class="clusterlane-review-muted">' + glyph( 'sparkle' ) + 'Checking…</span>';
+        else if ( r.status === 'error' ) res = '<span class="clusterlane-split-error">' + esc( r.error || 'Failed' ) + '</span>';
+        else if ( ! s ) res = '<span class="clusterlane-review-muted">Not checked</span>';
+        else {
+            if ( s.groups && s.groups.length ) {
+                res += '<div class="clusterlane-review-line"><span class="clusterlane-review-tag clusterlane-review-tag--split">Split into ' + s.groups.length + ( s.by ? ' by ' + esc( s.by ) : '' ) + '</span>'
+                    + s.groups.map( function ( g ) { return '<span class="clusterlane-review-chip">' + ( g.idea ? glyph( 'bulb' ) : '' ) + esc( g.name ) + ' <em>' + g.n + '</em></span>'; } ).join( '' )
+                    + ( s.alt_by ? '<span class="clusterlane-review-muted"> · or by ' + esc( s.alt_by ) + '</span>' : '' ) + '</div>';
+            }
+            if ( s.pillar_idea ) res += '<div class="clusterlane-review-line"><span class="clusterlane-review-tag clusterlane-review-tag--idea">No main article</span><span class="clusterlane-review-text">worth writing: “' + esc( s.pillar_idea ) + '”</span></div>';
+            else if ( s.better_pillar ) res += '<div class="clusterlane-review-line"><span class="clusterlane-review-tag clusterlane-review-tag--pillar">Better pillar</span><span class="clusterlane-review-text">' + esc( s.better_pillar.title ) + ( r.pillar && r.pillar.title ? ' <span class="clusterlane-review-muted">(now: ' + esc( r.pillar.title ) + ')</span>' : '' ) + '</span></div>';
+            if ( ( ! s.groups || ! s.groups.length ) && ! s.pillar_idea && ! s.better_pillar ) res += '<div class="clusterlane-review-line"><span class="clusterlane-review-tag clusterlane-review-tag--ok">' + glyph( 'check' ) + 'Good</span><span class="clusterlane-review-text">' + esc( s.note || 'One topic with a fitting main article.' ) + '</span></div>';
+            if ( r.status === 'stale' ) res += '<div class="clusterlane-review-muted clusterlane-review-stale">Articles have changed since this check' + ( r.generated_at ? ' (' + esc( ago( r.generated_at ) ) + ')' : '' ) + '.</div>';
+        }
+        var url = ( R.ciUrl || 'admin.php?page=clusterlane-cluster-inspector' ) + '&open=' + encodeURIComponent( r.key ) + '&focus=split';
+        var type = typeof window.clusterlaneType === 'function' ? window.clusterlaneType( r.type ).label : r.type;
+        return '<div class="clusterlane-review-row" data-key="' + esc( r.key ) + '">'
+            + '<div class="clusterlane-review-name"><strong>' + esc( r.label ) + '</strong><span>' + esc( type + ' · ' + pl( r.members, 'article', 'articles' ) + ' · health ' + r.health + '%' ) + '</span></div>'
+            + '<div class="clusterlane-review-result">' + res + '</div>'
+            + '<div class="clusterlane-review-actions">'
+            + ( needs( r ) && ! S.running ? '<button type="button" class="clusterlane-tm-btn" data-review-one="' + esc( r.key ) + '">Check</button>' : '' )
+            + ( S.example ? '<button type="button" class="clusterlane-tm-btn">Review →</button>' : '<a class="clusterlane-tm-btn" href="' + esc( url ) + '">Review →</a>' )
+            + '</div></div>';
+    }
+    /** Re-render, keeping the scroll position. */
+    function refresh() {
+        var top = dlg.scrollTop;
+        render();
+        dlg.scrollTop = top;
+    }
+
+    /* ── Checks ─────────────────────────────────────────────────────── */
+    function check( r ) {
+        return new Promise( function ( resolve ) {
+            r.status = 'checking'; refresh();
+            window.clusterlanePro.splitLoad( { REST: R.restUrl, nonce: R.nonce, key: r.key, generate: true, force: false,
+                done: function ( d ) { r.summary = d.summary || null; r.status = 'fresh'; r.generated_at = d.generated_at || ''; resolve(); },
+                fail: function ( m ) { r.status = 'error'; r.error = m; resolve(); }
+            } );
+        } );
+    }
+    async function runAll() {
+        if ( S.running || ! live() ) return;
+        var list = S.rows.filter( needs );
+        if ( ! list.length ) return;
+        S.running = true; S.stop = false; S.done = 0; S.todo = list.length;
+        for ( var i = 0; i < list.length; i++ ) {
+            if ( S.stop ) break;
+            await check( list[ i ] );
+            S.done++;
+            if ( list[ i ].status === 'error' && /limit|quota|429/i.test( list[ i ].error || '' ) ) break; // AI limit reached: stop, keep what is done
+        }
+        S.running = false; refresh();
+    }
+
+    body.addEventListener( 'click', function ( e ) {
+        var b = e.target.closest( 'button' ); if ( ! b || b.disabled ) return;
+        var f = b.getAttribute( 'data-review-filter' );
+        if ( f ) { S.filter = f; refresh(); return; }
+        if ( S.example ) return;
+        if ( b.hasAttribute( 'data-review-all' ) ) { runAll(); return; }
+        if ( b.hasAttribute( 'data-review-stop' ) ) { S.stop = true; b.disabled = true; b.textContent = 'Stopping…'; return; }
+        var one = b.getAttribute( 'data-review-one' );
+        if ( one && ! S.running && live() ) {
+            var r = S.rows.filter( function ( x ) { return x.key === one; } )[0];
+            if ( r ) check( r ).then( refresh );
+        }
+    } );
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/cluster-split.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/cluster-split.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/cluster-split.js	(revision 3751027)
@@ -0,0 +1,455 @@
+/* Clusterlane — Cluster Inspector → Improve → "Split this cluster" (0.98.138).
+ *
+ * Shows AI's suggestion for smaller clusters inside a broad one: a card per
+ * suggested cluster (rename, pillar medal, create or not), the articles with
+ * one round button per suggested cluster (an article may be in two), the
+ * cluster's link graph coloured by suggestion, and how ready each group is
+ * (links inside it vs. out). Nothing is written until "Create".
+ *
+ * Live suggestions and Create are Pro (window.clusterlanePro.splitLoad / splitCreate
+ * in admin/js/pro__premium_only/clusterlane-pro.js); Free shows the demo example.
+ * Shown for clusters of 20+ articles, or 12+ that the metrics call "Mixed".
+ */
+( function () {
+    'use strict';
+    var block = document.getElementById( 'clusterlane-improve-split' );
+    if ( ! block ) return;
+    var body   = document.getElementById( 'clusterlane-improve-split-body' );
+    var count  = document.getElementById( 'clusterlane-improve-split-count' );
+    var header = block.querySelector( '.clusterlane-improve-block-header' );
+    var D      = window.clusterlaneData || {};
+    var REST   = D.restUrl || '';
+    var CAN_MANAGE = parseInt( D.canManage, 10 ) === 1;
+    var COLORS = [ '#0f766e', '#6d28d9', '#be185d', '#1d4ed8' ];
+    var STAY   = '#94a3b8';
+    var S = null, net = null, visNodes = null, visEdges = null, currentKey = '';
+
+    function esc( s ) { return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' ); }
+    function glyph( n ) { return typeof window.clusterlaneGlyph === 'function' ? window.clusterlaneGlyph( n ) : ''; }
+    function thumb( m ) { return typeof window.clusterlaneThumb === 'function' ? window.clusterlaneThumb( m.thumb || '', m.title, m.role || '', 'xs' ) : ''; }
+    function ago( t ) {
+        if ( ! t ) return '';
+        var s = Math.max( 0, ( Date.now() - new Date( t.replace( ' ', 'T' ) + 'Z' ).getTime() ) / 1000 );
+        if ( s < 90 ) return 'just now';
+        if ( s < 5400 ) return Math.round( s / 60 ) + ' min ago';
+        if ( s < 129600 ) return Math.round( s / 3600 ) + ' h ago';
+        return Math.round( s / 86400 ) + ' days ago';
+    }
+    function pl( n, one, many ) { return n + ' ' + ( n === 1 ? one : many ); }
+    function live() { return typeof clusterlaneProReady === 'function' && clusterlaneProReady() && window.clusterlanePro && window.clusterlanePro.splitLoad; }
+
+    if ( header ) header.addEventListener( 'click', function () {
+        var open = header.classList.toggle( 'is-open' );
+        body.classList.toggle( 'is-open', open );
+        if ( open && net ) setTimeout( function () { net.fit(); }, 30 );
+    } );
+
+    /* ── Public: Cluster Inspector opens / closes a cluster ─────────── */
+    window.clusterlaneSplit = {
+        reset: function () {
+            currentKey = ''; S = null; destroyNet();
+            block.hidden = true; body.innerHTML = ''; count.style.display = 'none';
+            header.classList.remove( 'is-open' ); body.classList.remove( 'is-open' );
+        },
+        open: function ( cluster ) {
+            window.clusterlaneSplit.reset();
+            if ( ! cluster ) return;
+            var n = +cluster.members || 0;
+            if ( n < 6 ) return;   // two groups of three (0.98.141: every cluster that could split — and the pillar check helps small ones too)
+            currentKey = cluster.key;
+            block.hidden = false;
+            if ( ! live() ) {
+                var ex = typeof clusterlaneProExample === 'function' ? clusterlaneProExample( 'cluster_split' ) : null;
+                if ( ! ex ) { block.hidden = true; return; }
+                setData( ex, true );
+                return;
+            }
+            body.innerHTML = '<p class="clusterlane-improve-loading">Checking for a saved suggestion…</p>';
+            window.clusterlanePro.splitLoad( { REST: REST, nonce: D.nonce, key: currentKey, force: false, done: onData, fail: onFail } );
+        }
+    };
+
+    function run( force ) {
+        if ( ! currentKey || ! live() ) return;
+        destroyNet();
+        body.innerHTML = '<p class="clusterlane-improve-loading">' + glyph( 'sparkle' ) + 'Reading the articles and grouping them — this takes up to a minute…</p>';
+        window.clusterlanePro.splitLoad( { REST: REST, nonce: D.nonce, key: currentKey, force: !! force, generate: true, done: onData, fail: onFail } );
+    }
+    function onData( data, key ) {
+        if ( key !== currentKey ) return;
+        if ( data.needs_generation ) { renderStart( data ); return; }
+        setData( data, false );
+    }
+    function onFail( msg, key ) {
+        if ( key !== currentKey ) return;
+        body.innerHTML = '<p class="clusterlane-improve-loading clusterlane-split-error">' + esc( msg ) + '</p>'
+            + '<p><button type="button" class="clusterlane-tm-btn" data-split-run>' + glyph( 'refresh' ) + 'Try again</button></p>';
+    }
+
+    function renderStart( data ) {
+        var n = ( data.members || [] ).length;
+        count.style.display = 'none';
+        body.innerHTML = '<div class="clusterlane-improve-cta">'
+            + '<p class="clusterlane-improve-cta-text">' + esc( 'This cluster has ' + n + ' articles. Let AI read their titles and first lines: does the cluster have a natural main article, and does it cover topics that would be better as smaller clusters? You decide what to do with the answer.' ) + '</p>'
+            + '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-split-run>' + glyph( 'sparkle' ) + 'Check pillar & split</button></div>';
+    }
+
+    /* ── State ───────────────────────────────────────────────────────── */
+    function setData( data, example ) {
+        // One way of dividing per suggestion (0.98.159): data.by names it; a
+        // second way (data.alternative) is offered as a switch, never mixed in.
+        var orig    = data._orig || data;
+        var choices = data._choices || ( orig.alternative && ( orig.alternative.groups || [] ).length && ( orig.groups || [] ).length
+            ? [ { by: orig.by || '', groups: orig.groups }, { by: orig.alternative.by || '', groups: orig.alternative.groups } ] : null );
+        var members = data.members || [], byId = {};
+        members.forEach( function ( m ) { byId[ m.id ] = m; } );
+        var assign = {};
+        members.forEach( function ( m ) { assign[ m.id ] = []; } );
+        var groups = ( data.groups || [] ).map( function ( g, i ) {
+            ( g.ids || [] ).forEach( function ( id ) { if ( assign[ id ] && assign[ id ].indexOf( i ) === -1 ) assign[ id ].push( i ); } );
+            return { name: g.name, why: g.why || '', color: COLORS[ i % COLORS.length ], pillar: +g.pillar || 0, on: true, idea: g.pillar_idea || '', draft: null };
+        } );
+        S = { data: data, orig: orig, choices: choices, choice: data._choice || 0, example: example, members: members, byId: byId, links: data.links || [], groups: groups, assign: assign, created: null };
+        // A Free example is the demo site's result: say so instead of counting it as this cluster's (0.98.185).
+        count.textContent = example ? 'PRO · example' : ( groups.length ? pl( groups.length, 'suggestion', 'suggestions' ) : ( data.cluster_pillar_idea ? 'pillar missing' : 'one topic' ) );
+        count.className = 'clusterlane-improve-count ' + ( example ? 'clusterlane-improve-count--pro' : 'clusterlane-improve-count--split' );
+        count.style.display = '';
+        render();
+    }
+    function inGroup( id, gi ) { return S.assign[ id ].indexOf( gi ) !== -1; }
+    function membersOf( gi ) { return S.members.filter( function ( m ) { return inGroup( m.id, gi ); } ); }
+    function stays() { return S.members.filter( function ( m ) { return S.assign[ m.id ].length === 0; } ); }
+    function pillarOf( gi ) {
+        var ms = membersOf( gi ), p = S.groups[ gi ].pillar;
+        if ( ms.some( function ( m ) { return m.id === p; } ) ) return p;
+        return ms.length ? ms[ 0 ].id : 0;   // members come best-linked first
+    }
+    /**
+     * One short label per suggestion for the row buttons: the first letter of
+     * the first word no other name has ("Swedish Beverages",
+     * "Swedish Baked Goods" → B, B), then more letters of it until they
+     * differ (Be, Ba); a number only when nothing else works.
+     */
+    function letters() {
+        var words = S.groups.map( function ( g ) { return ( g.name || '' ).split( /\s+/ ).filter( Boolean ); } );
+        var STOP = /^(the|and|&|in|of|och|i|av|med|for|för)$/i;
+        var inOther = function ( w, gi ) { return words.some( function ( ws, j ) { return j !== gi && ws.some( function ( x ) { return x.toLowerCase() === w.toLowerCase(); } ); } ); };
+        var key = words.map( function ( ws, gi ) {
+            var w = ws.filter( function ( x ) { return ! STOP.test( x ) && ! inOther( x, gi ); } )[0] || ws.filter( function ( x ) { return ! STOP.test( x ); } )[0] || ws[0] || '';
+            return w.replace( /[^\p{L}\p{N}]/gu, '' );
+        } );
+        for ( var len = 1; len <= 2; len++ ) {
+            var out = key.map( function ( k, i ) { var t = k.slice( 0, len ); return t ? t.charAt( 0 ).toUpperCase() + t.slice( 1 ).toLowerCase() : String( i + 1 ); } );
+            var seen = {}, dup = out.some( function ( l ) { var d = seen[ l.toLowerCase() ]; seen[ l.toLowerCase() ] = true; return d; } );
+            if ( ! dup ) return out;
+        }
+        return S.groups.map( function ( g, i ) { return String( i + 1 ); } );
+    }
+    function readiness( gi ) {
+        var inside = 0, out = 0;
+        S.links.forEach( function ( l ) {
+            var a = S.assign[ l[0] ] && inGroup( l[0], gi ), b = S.assign[ l[1] ] && inGroup( l[1], gi );
+            if ( a && b ) inside++; else if ( a || b ) out++;
+        } );
+        var n = membersOf( gi ).length, r = inside + out ? inside / ( inside + out ) : 0;
+        var verdict = n < 3 ? 'Too few articles for a cluster of its own — at least 3.'
+            : r >= 0.6 ? 'Already linked together — a natural cluster.'
+            : r >= 0.3 ? 'Partly linked — Text Match finds more links once it is a cluster.'
+            : 'Hardly linked to each other yet — plan some links after creating it.';
+        return { n: n, inside: inside, out: out, pct: Math.round( r * 100 ), verdict: verdict };
+    }
+
+    /* ── Render ──────────────────────────────────────────────────────── */
+    function render() {
+        destroyNet();
+        var d = S.data, G = S.groups;
+        var meta = S.example ? '' : '<span class="clusterlane-split-meta">' + ( d.cached ? 'Cached' : 'New' ) + ( d.generated_at ? ' · ' + esc( ago( d.generated_at ) ) : '' ) + '</span>'
+            + '<button type="button" class="clusterlane-tm-btn clusterlane-split-regen" data-split-run="force" title="Run the check again — a new AI call">' + glyph( 'sparkle' ) + 'Regenerate</button>';
+        var html = ( S.example && typeof clusterlaneProBanner === 'function' ? clusterlaneProBanner( 'Pillar & split check' ) : '' )
+            + '<div class="clusterlane-split-head"><p class="clusterlane-split-note">' + glyph( 'target' ) + '<span data-split-summary></span></p>' + meta + '</div>'
+            + byChoiceHtml()
+            + ( d.stale ? '<p class="clusterlane-split-stale">Articles have joined or left the cluster since this check — Regenerate for a fresh one.</p>' : '' )
+            + '<div data-split-cpillar>' + clusterPillarHtml() + '</div>';
+        if ( ! G.length ) {
+            body.innerHTML = html + '<p class="clusterlane-split-onetopic">' + glyph( 'check' ) + esc( d.note || 'No split suggested — the articles look like one topic.' ) + '</p>';
+            renderSummary();
+            if ( S.example ) clusterlaneFreezeExample( body );
+            return;
+        }
+        html += '<div class="clusterlane-split-top">'
+            + '<div class="clusterlane-split-graphbox"><div class="clusterlane-split-graph-title">Links today, coloured by suggestion</div>'
+            + '<div class="clusterlane-split-graph" id="clusterlane-split-graph" role="img" aria-label="Link graph of this cluster, coloured by suggested cluster"></div>'
+            + '<div class="clusterlane-split-legend" data-split-legend></div>'
+            + '<p class="clusterlane-split-hint">A ring in a second colour: in two suggested clusters. Orange ring: a suggested pillar.</p></div>'
+            + '<div class="clusterlane-split-side"><div class="clusterlane-split-ready" data-split-ready></div>'
+            + '<p class="clusterlane-split-overlap"><strong>Articles stay in ' + esc( d.cluster && d.cluster.label || 'this cluster' ) + ' too.</strong> It remains the broad hub; the new clusters go deeper. WordPress categories are not changed — each new cluster is a custom cluster you can remove in Cluster Admin.</p></div>'
+            + '</div>'
+            + '<div class="clusterlane-split-cards" data-split-cards></div>'
+            + '<div class="clusterlane-split-foot" data-split-foot></div>';
+        body.innerHTML = html;
+        renderSummary(); renderCards(); renderSide(); renderFoot(); drawGraph();
+        if ( S.example ) clusterlaneFreezeExample( body );
+    }
+    /** Two ways of dividing: "Split by  [area] [architectural style]" (0.98.159). */
+    function byChoiceHtml() {
+        if ( ! S.choices ) return '';
+        return '<div class="clusterlane-split-by" role="group" aria-label="Ways to split"><span class="clusterlane-split-by-label">Split by</span>'
+            + S.choices.map( function ( c, i ) {
+                return '<button type="button" class="clusterlane-split-by-btn' + ( i === S.choice ? ' is-active' : '' ) + '" data-split-by="' + i + '" aria-pressed="' + ( i === S.choice ) + '">'
+                    + esc( c.by || ( i ? 'second option' : 'first option' ) ) + ' <em>' + c.groups.length + '</em></button>';
+            } ).join( '' )
+            + '<span class="clusterlane-split-by-hint">Two ways to divide these articles — pick one; they are never mixed.</span></div>';
+    }
+
+    /** The whole cluster's main article (0.98.141): AI's pick, or an overview worth writing. */
+    function clusterPillarHtml() {
+        var d = S.data, label = d.cluster && d.cluster.label || 'this cluster';
+        var cur = d.current_pillar || { id: 0, title: '' }, pick = +d.cluster_pillar || 0;
+        if ( d.cluster_pillar_idea ) {
+            var actions = S.clusterDraft
+                ? '<a class="clusterlane-tm-btn clusterlane-gap-draft-done" href="' + esc( S.clusterDraft ) + '" target="_blank" rel="noopener">' + glyph( 'check' ) + 'Draft created — Edit</a>'
+                : '<button type="button" class="clusterlane-tm-btn" data-split-brief="c" title="AI writes a content brief and copies it to your clipboard">' + glyph( 'sparkle' ) + 'Copy brief</button>'
+                  + '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-split-draft="c" title="AI writes a brief and puts it in an unpublished WordPress draft — nothing is published">' + glyph( 'sparkle' ) + 'Create draft</button>';
+            return '<div class="clusterlane-split-idea clusterlane-split-idea--cluster">' + glyph( 'bulb' ) + '<div class="clusterlane-split-idea-text"><span>' + esc( label ) + ' has no natural main article — worth writing:</span> <strong>' + esc( d.cluster_pillar_idea ) + '</strong>'
+                + '<small>' + esc( cur.id ? 'Until then, “' + cur.title + '” stays the pillar.' : 'Until then, choose the best article as pillar in the list below.' ) + ' Publish it and give it the medal — the link boxes follow.</small></div>'
+                + '<div class="clusterlane-split-idea-actions">' + actions + '</div></div>';
+        }
+        if ( pick && pick !== cur.id && S.byId[ pick ] ) {
+            return '<div class="clusterlane-split-pick">' + glyph( 'award' ) + '<span>AI’s pick for the main article of ' + esc( label ) + ': <strong>' + esc( S.byId[ pick ].title ) + '</strong>'
+                + ( cur.id ? ' <span class="clusterlane-split-pick-now">(now: ' + esc( cur.title ) + ( cur.chosen ? ', chosen' : ', the best-linked' ) + ')</span>' : '' ) + '</span>'
+                + ( CAN_MANAGE && ! S.example ? '<button type="button" class="clusterlane-tm-btn" data-split-makepillar="' + pick + '">Make pillar</button>' : '' ) + '</div>';
+        }
+        if ( pick && pick === cur.id ) {
+            return '<p class="clusterlane-split-pick clusterlane-split-pick--ok">' + glyph( 'check' ) + '<span>The pillar, <strong>' + esc( cur.title ) + '</strong>, works as the main article of ' + esc( label ) + '.</span></p>';
+        }
+        return '';
+    }
+
+    function renderSummary() {
+        var el = body.querySelector( '[data-split-summary]' ); if ( ! el ) return;
+        var multi = S.members.filter( function ( m ) { return S.assign[ m.id ].length > 1; } ).length;
+        var label = S.data.cluster && S.data.cluster.label || 'This cluster';
+        el.textContent = S.groups.length
+            ? label + ' has ' + S.members.length + ' articles. AI suggests ' + pl( S.groups.length, 'smaller cluster', 'smaller clusters' ) + ( S.data.by ? ' by ' + S.data.by : '' )
+              + ( multi ? '; ' + pl( multi, 'article fits', 'articles fit' ) + ' two of them' : '' ) + ', ' + pl( stays().length, 'article fits', 'articles fit' ) + ' none and stay' + ( stays().length === 1 ? 's' : '' ) + ' in ' + label + ' only.'
+              + ( S.data.note ? ' ' + S.data.note : '' )
+            : label + ': no split suggested.';
+    }
+    function row( m, gi, L ) {
+        var mine = S.assign[ m.id ];
+        var others = gi === null ? [] : mine.filter( function ( x ) { return x !== gi; } );
+        var isP = gi !== null && pillarOf( gi ) === m.id;
+        var chips = S.groups.map( function ( g, i ) {
+            var on = mine.indexOf( i ) !== -1;
+            return '<button type="button" class="clusterlane-split-chip' + ( on ? ' is-on' : '' ) + '" style="--c:' + g.color + '" data-split-toggle="' + m.id + ':' + i + '" aria-pressed="' + on + '"'
+                + ' aria-label="' + esc( ( on ? 'In ' : 'Add to ' ) + g.name ) + '" title="' + esc( on ? 'In ' + g.name + ' — click to take it out' : 'Add to ' + g.name ) + '">' + esc( L[ i ] ) + '</button>';
+        } ).join( '' );
+        return '<div class="clusterlane-split-row">' + thumb( m )
+            + '<span class="clusterlane-split-title" title="' + esc( m.title ) + '">' + esc( m.title ) + '</span>'
+            + ( gi === null && m.role === 'pillar' ? '<span class="clusterlane-split-tag clusterlane-split-tag--hub">Pillar here</span>' : '' )
+            + ( others.length ? '<span class="clusterlane-split-tag">also ' + esc( others.map( function ( x ) { return S.groups[ x ].name; } ).join( ', ' ) ) + '</span>' : '' )
+            + ( gi !== null ? '<button type="button" class="clusterlane-tm-btn clusterlane-ci-pillar-btn' + ( isP ? ' is-chosen' : '' ) + '" data-split-pillar="' + m.id + ':' + gi + '" aria-pressed="' + isP + '"'
+                + ' aria-label="' + esc( isP ? 'Pillar of ' + S.groups[ gi ].name : 'Make ' + m.title + ' the pillar of ' + S.groups[ gi ].name ) + '" title="' + esc( isP ? 'Pillar of this cluster' : 'Make this the pillar' ) + '">' + glyph( 'award' ) + '</button>' : '' )
+            + '<span class="clusterlane-split-chips" role="group" aria-label="' + esc( 'Suggested clusters for ' + m.title ) + '">' + chips + '</span></div>';
+    }
+    function renderCards() {
+        var el = body.querySelector( '[data-split-cards]' ); if ( ! el ) return;
+        var L = letters();
+        var cards = S.groups.map( function ( g, gi ) {
+            var ms = membersOf( gi );
+            return '<section class="clusterlane-split-card' + ( g.on ? '' : ' is-off' ) + '" style="--c:' + g.color + '" data-split-card="' + gi + '">'
+                + '<div class="clusterlane-split-card-head"><span class="clusterlane-split-dot"></span>'
+                + '<input type="text" class="clusterlane-split-name" value="' + esc( g.name ) + '" data-split-name="' + gi + '" aria-label="Name of the new cluster" maxlength="100">'
+                + '<span class="clusterlane-split-count">' + pl( ms.length, 'article', 'articles' ) + '</span></div>'
+                + ( g.why ? '<p class="clusterlane-split-why">' + esc( g.why ) + '</p>' : '' )
+                + ideaHtml( g, gi )
+                + '<div class="clusterlane-split-rows">' + ( ms.length ? ms.map( function ( m ) { return row( m, gi, L ); } ).join( '' ) : '<p class="clusterlane-split-empty">No articles — add some with the ' + esc( L[ gi ] ) + ' button.</p>' ) + '</div>'
+                + '<label class="clusterlane-split-create"><input type="checkbox" data-split-on="' + gi + '"' + ( g.on ? ' checked' : '' ) + '> Create this cluster'
+                + ( pillarOf( gi ) ? ' · pillar: <strong>' + esc( S.byId[ pillarOf( gi ) ].title ) + '</strong>' : '' ) + '</label></section>';
+        } ).join( '' );
+        var st = stays(), label = S.data.cluster && S.data.cluster.label || 'this cluster';
+        cards += '<section class="clusterlane-split-card clusterlane-split-card--stay" style="--c:' + STAY + '">'
+            + '<div class="clusterlane-split-card-head"><span class="clusterlane-split-dot"></span><strong class="clusterlane-split-stayname">' + esc( 'Stays in ' + label + ' only' ) + '</strong>'
+            + '<span class="clusterlane-split-count">' + pl( st.length, 'article', 'articles' ) + '</span></div>'
+            + '<p class="clusterlane-split-why">' + esc( 'Too broad or too different to fit a smaller cluster. Add one to a suggested cluster with its letter.' ) + '</p>'
+            + '<div class="clusterlane-split-rows">' + ( st.length ? st.map( function ( m ) { return row( m, null, L ); } ).join( '' ) : '<p class="clusterlane-split-empty">Every article is in a suggested cluster.</p>' ) + '</div></section>';
+        el.innerHTML = cards;
+    }
+    /** No natural pillar among the articles: the overview AI suggests writing, with Copy brief / Create draft (the Gap Analysis tools). */
+    function ideaHtml( g, gi ) {
+        if ( ! g.idea ) return '';
+        var actions = g.draft
+            ? '<a class="clusterlane-tm-btn clusterlane-gap-draft-done" href="' + esc( g.draft ) + '" target="_blank" rel="noopener">' + glyph( 'check' ) + 'Draft created — Edit</a>'
+            : '<button type="button" class="clusterlane-tm-btn" data-split-brief="' + gi + '" title="AI writes a content brief and copies it to your clipboard">' + glyph( 'sparkle' ) + 'Copy brief</button>'
+              + '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-split-draft="' + gi + '" title="AI writes a brief and puts it in an unpublished WordPress draft — nothing is published">' + glyph( 'sparkle' ) + 'Create draft</button>';
+        return '<div class="clusterlane-split-idea">' + glyph( 'bulb' ) + '<div class="clusterlane-split-idea-text"><span>No natural pillar yet — worth writing:</span> <strong>' + esc( g.idea ) + '</strong>'
+            + '<small>Until it exists, the pillar below stands in. Publish it, add it to the cluster and give it the medal — the link boxes follow.</small></div>'
+            + '<div class="clusterlane-split-idea-actions">' + actions + '</div></div>';
+    }
+    function ideaRationale( gi ) {
+        var g = S.groups[ gi ], titles = membersOf( gi ).map( function ( m ) { return m.title; } ).slice( 0, 12 );
+        return 'The main (pillar) article of a new cluster "' + g.name.trim() + '": an overview that introduces the topic and links to each of its articles — ' + titles.join( '; ' ) + '.';
+    }
+    function gapAction( btn, kind ) {
+        if ( ! window.clusterlanePro || ! window.clusterlanePro.splitGap ) return;
+        var attr = btn.getAttribute( kind === 'brief' ? 'data-split-brief' : 'data-split-draft' ), whole = attr === 'c';
+        var gi = +attr, g = whole ? null : S.groups[ gi ], orig = btn.innerHTML;
+        var label = S.data.cluster && S.data.cluster.label || 'this cluster';
+        btn.disabled = true; btn.innerHTML = glyph( 'sparkle' ) + 'Writing brief…';
+        window.clusterlanePro.splitGap( {
+            REST: REST, nonce: D.nonce, kind: kind, key: currentKey,
+            title: whole ? S.data.cluster_pillar_idea : g.idea,
+            rationale: whole ? 'The main (pillar) article of the cluster "' + label + '": an overview that introduces the topic and links to its articles — ' + S.members.slice( 0, 15 ).map( function ( m ) { return m.title; } ).join( '; ' ) + '.' : ideaRationale( gi ),
+            done: function ( res ) {
+                if ( kind === 'draft' && res.edit_url ) {
+                    if ( whole ) { S.clusterDraft = res.edit_url; var el = body.querySelector( '[data-split-cpillar]' ); if ( el ) el.innerHTML = clusterPillarHtml(); }
+                    else { g.draft = res.edit_url; renderCards(); }
+                    return;
+                }
+                btn.innerHTML = glyph( 'check' ) + 'Copied — paste it anywhere';
+                setTimeout( function () { btn.innerHTML = orig; btn.disabled = false; }, 2200 );
+            },
+            fail: function ( msg ) { btn.textContent = 'Error'; btn.title = msg; setTimeout( function () { btn.innerHTML = orig; btn.disabled = false; }, 3000 ); }
+        } );
+    }
+
+    function renderSide() {
+        var el = body.querySelector( '[data-split-ready]' ); if ( ! el ) return;
+        el.innerHTML = '<div class="clusterlane-split-ready-title">How ready each group is</div>' + S.groups.map( function ( g, gi ) {
+            var r = readiness( gi );
+            return '<div class="clusterlane-split-ready-row" style="--c:' + g.color + '"><div class="clusterlane-split-ready-top"><span class="clusterlane-split-dot"></span><strong>' + esc( g.name ) + '</strong>'
+                + '<span>' + pl( r.n, 'article', 'articles' ) + ' · ' + r.inside + ' links inside · ' + r.out + ' out</span></div>'
+                + '<div class="clusterlane-split-bar"><span style="width:' + r.pct + '%"></span></div><div class="clusterlane-split-verdict">' + esc( r.verdict ) + '</div></div>';
+        } ).join( '' );
+        var lg = body.querySelector( '[data-split-legend]' );
+        if ( lg ) lg.innerHTML = S.groups.map( function ( g ) { return '<span><span class="clusterlane-split-dot" style="--c:' + g.color + '"></span>' + esc( g.name ) + '</span>'; } ).join( '' )
+            + '<span><span class="clusterlane-split-dot" style="--c:' + STAY + '"></span>' + esc( 'Stays in ' + ( S.data.cluster && S.data.cluster.label || 'this cluster' ) ) + '</span>';
+    }
+    function toCreate() {
+        return S.groups.map( function ( g, gi ) { return { g: g, gi: gi }; } ).filter( function ( x ) { return x.g.on && membersOf( x.gi ).length >= 2 && x.g.name.trim(); } );
+    }
+    function renderFoot() {
+        var el = body.querySelector( '[data-split-foot]' ); if ( ! el ) return;
+        if ( S.created ) {
+            el.innerHTML = '<p class="clusterlane-split-done" role="status">' + glyph( 'check' ) + esc( 'Created: ' + S.created.map( function ( c ) { return c.label; } ).join( ', ' ) + '. Each has its pillar chosen, so the pillar link box points there. They appear in the cluster list after a reload.' )
+                + ' <a href="' + esc( S.createdUrl || 'admin.php?page=clusterlane-cluster-settings' ) + '">Cluster Admin →</a></p>';
+            return;
+        }
+        var list = toCreate();
+        var txt = list.length ? 'Creates ' + list.map( function ( x ) { return '“' + x.g.name.trim() + '”'; } ).join( ', ' ) + ' as custom clusters, each with the pillar chosen above.' : 'Tick at least one cluster to create.';
+        el.innerHTML = '<span class="clusterlane-split-foot-text">' + esc( txt ) + '</span>'
+            + ( CAN_MANAGE || S.example
+                ? '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-split-create' + ( list.length ? '' : ' disabled' ) + '>' + esc( list.length ? 'Create ' + pl( list.length, 'cluster', 'clusters' ) : 'Nothing to create' ) + '</button>'
+                : '<span class="clusterlane-split-foot-note">Only administrators can create clusters.</span>' );
+    }
+
+    /* ── Graph (vis-network, loaded on this page) ───────────────────── */
+    function destroyNet() { if ( net ) { net.destroy(); net = null; } visNodes = visEdges = null; }
+    function nodeStyle( m ) {
+        var gs = S.assign[ m.id ], first = gs.length ? S.groups[ gs[0] ].color : STAY;
+        var isP = S.groups.some( function ( g, gi ) { return pillarOf( gi ) === m.id; } );
+        var border = isP ? '#e76c2e' : ( gs[1] !== undefined ? S.groups[ gs[1] ].color : '#ffffff' );
+        return { id: m.id, color: { background: first, border: border, highlight: { background: first, border: border } },
+            borderWidth: isP || gs[1] !== undefined ? 4 : 1.5, size: m.role === 'pillar' ? 16 : ( isP ? 13 : 9 ),
+            title: m.title + ( gs.length ? ' — ' + gs.map( function ( i ) { return S.groups[ i ].name; } ).join( ' + ' ) : '' ) };
+    }
+    function edgeStyle( l, i ) {
+        var a = S.assign[ l[0] ] || [], b = S.assign[ l[1] ] || [];
+        var shared = a.filter( function ( x ) { return b.indexOf( x ) !== -1; } );
+        var c = shared.length ? S.groups[ shared[0] ].color : '#cbd5e1';
+        return { id: i, from: l[0], to: l[1], color: { color: c, opacity: shared.length ? 0.85 : 0.6 }, width: shared.length ? 2 : 1 };
+    }
+    function drawGraph() {
+        var box = document.getElementById( 'clusterlane-split-graph' );
+        if ( ! box || ! window.vis || ! window.vis.Network ) { if ( box ) box.innerHTML = '<p class="clusterlane-improve-loading">Graph not available.</p>'; return; }
+        visNodes = new vis.DataSet( S.members.map( function ( m ) { return Object.assign( { shape: 'dot' }, nodeStyle( m ) ); } ) );
+        visEdges = new vis.DataSet( S.links.filter( function ( l ) { return S.byId[ l[0] ] && S.byId[ l[1] ]; } ).map( edgeStyle ) );
+        net = new vis.Network( box, { nodes: visNodes, edges: visEdges }, {
+            physics: { solver: 'forceAtlas2Based', forceAtlas2Based: { gravitationalConstant: -40, springLength: 70 }, stabilization: { iterations: 250 } },
+            interaction: { hover: true, zoomView: false, dragView: true, tooltipDelay: 120 },
+            edges: { smooth: false, arrows: { to: { enabled: false } } },
+            nodes: { font: { size: 0 } }
+        } );
+        net.once( 'stabilizationIterationsDone', function () { net.setOptions( { physics: false } ); net.fit(); } );
+    }
+    function recolor() {
+        if ( ! visNodes ) return;
+        visNodes.update( S.members.map( nodeStyle ) );
+        visEdges.update( S.links.filter( function ( l ) { return S.byId[ l[0] ] && S.byId[ l[1] ]; } ).map( edgeStyle ) );
+    }
+    function changed() { S.created = null; renderSummary(); renderCards(); renderSide(); renderFoot(); recolor(); }
+
+    /* ── Events ──────────────────────────────────────────────────────── */
+    body.addEventListener( 'click', function ( e ) {
+        var b = e.target.closest( 'button' );
+        if ( ! b || b.disabled ) return;
+        if ( b.hasAttribute( 'data-split-run' ) ) { run( b.getAttribute( 'data-split-run' ) === 'force' || !! S ); return; }
+        if ( S && b.hasAttribute( 'data-split-by' ) ) {
+            var ci = +b.getAttribute( 'data-split-by' ), ch = S.choices && S.choices[ ci ];
+            if ( ch && ci !== S.choice ) setData( Object.assign( {}, S.orig, { groups: ch.groups, by: ch.by, _choices: S.choices, _choice: ci, _orig: S.orig } ), S.example );
+            return;
+        }
+        if ( ! S || S.example ) return;
+        var t = b.getAttribute( 'data-split-toggle' );
+        if ( t ) {
+            var p = t.split( ':' ), id = +p[0], gi = +p[1], mine = S.assign[ id ];
+            S.assign[ id ] = mine.indexOf( gi ) !== -1 ? mine.filter( function ( x ) { return x !== gi; } ) : mine.concat( [ gi ] ).sort();
+            changed(); return;
+        }
+        var pp = b.getAttribute( 'data-split-pillar' );
+        if ( pp ) { var q = pp.split( ':' ); S.groups[ +q[1] ].pillar = +q[0]; changed(); return; }
+        var mp = b.getAttribute( 'data-split-makepillar' );
+        if ( mp ) { makePillar( b, +mp ); return; }
+        if ( b.hasAttribute( 'data-split-brief' ) ) { gapAction( b, 'brief' ); return; }
+        if ( b.hasAttribute( 'data-split-draft' ) ) { gapAction( b, 'draft' ); return; }
+        if ( b.hasAttribute( 'data-split-create' ) ) create( b );
+    } );
+    body.addEventListener( 'input', function ( e ) {
+        var n = e.target.getAttribute( 'data-split-name' );
+        if ( n === null || ! S ) return;
+        S.groups[ +n ].name = e.target.value;
+        renderSummary(); renderSide(); renderFoot();
+    } );
+    body.addEventListener( 'change', function ( e ) {
+        if ( e.target.getAttribute( 'data-split-name' ) !== null && S ) { // letters follow the new name; keep focus where it went
+            setTimeout( function () {
+                var a = document.activeElement, n = a && body.contains( a ) ? a.getAttribute( 'data-split-name' ) : null;
+                renderCards();
+                if ( n !== null ) { var el = body.querySelector( '[data-split-name="' + n + '"]' ); if ( el ) el.focus(); }
+            }, 0 );
+            return;
+        }
+        var o = e.target.getAttribute( 'data-split-on' );
+        if ( o === null || ! S ) return;
+        S.groups[ +o ].on = e.target.checked;
+        var card = e.target.closest( '.clusterlane-split-card' ); if ( card ) card.classList.toggle( 'is-off', ! e.target.checked ); // greyed out: won't be created
+        S.created = null; renderFoot();
+    } );
+
+    /** AI's pick becomes the chosen pillar (same as the medal in the list), then the cluster reloads. */
+    function makePillar( btn, id ) {
+        btn.disabled = true; btn.textContent = 'Saving…';
+        fetch( REST + 'cluster-inspector/pillar', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': D.nonce }, body: JSON.stringify( { key: currentKey, post_id: id } ) } )
+            .then( function ( r ) { return r.json().then( function ( j ) { if ( ! r.ok ) throw new Error( j.error || j.message || 'HTTP ' + r.status ); return j; } ); } )
+            .then( function () { if ( typeof window.clusterlaneReloadCluster === 'function' ) window.clusterlaneReloadCluster( currentKey, 'split' ); else location.reload(); } )
+            .catch( function ( e ) { btn.disabled = false; btn.textContent = 'Make pillar'; alert( e.message ); } );
+    }
+
+    function create( btn ) {
+        if ( ! window.clusterlanePro || ! window.clusterlanePro.splitCreate ) return;
+        var list = toCreate();
+        if ( ! list.length ) return;
+        btn.disabled = true; btn.textContent = 'Creating…';
+        var key = currentKey;
+        window.clusterlanePro.splitCreate( {
+            REST: REST, nonce: D.nonce, key: key,
+            groups: list.map( function ( x ) { return { name: x.g.name.trim(), ids: membersOf( x.gi ).map( function ( m ) { return m.id; } ), pillar: pillarOf( x.gi ) }; } ),
+            done: function ( res ) {
+                if ( key !== currentKey ) return;
+                S.created = res.created || []; S.createdUrl = res.admin_url; renderFoot();
+                if ( typeof window.clusterlaneReloadClusters === 'function' ) window.clusterlaneReloadClusters();
+            },
+            fail: function ( msg ) { btn.disabled = false; renderFoot(); var f = body.querySelector( '[data-split-foot]' ); if ( f ) f.insertAdjacentHTML( 'afterbegin', '<span class="clusterlane-split-error">' + esc( msg ) + '</span>' ); }
+        } );
+    }
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/clusterlane-cluster-quick.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/clusterlane-cluster-quick.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/clusterlane-cluster-quick.js	(revision 3751027)
@@ -0,0 +1,332 @@
+/* Clusterlane — cluster quick view (0.98.200).
+ *
+ * A modal with the figures that tell in a glance how one cluster is doing and
+ * where to start: health and its trend, articles, links, orphans, role mix,
+ * pillar, meta descriptions and the articles to start with. Read-only.
+ *
+ * Opened by any element with data-clusterlane-quick="<cluster key>" (Report's cluster
+ * table, the Cluster Inspector list). ← / → step through the other clusters
+ * listed on the page, in the page's order. Data: GET clusterlane/v1/cluster-inspector/quick/{key}.
+ *
+ * A page can take over "Open cluster" with window.clusterlaneClusterQuickOpen = key => …
+ * (Cluster Inspector opens the cluster in place instead of reloading).
+ */
+( function () {
+    'use strict';
+    var D = window.clusterlaneQuickData || {};
+    if ( ! D.rest ) return;
+
+    var cache = {};
+    var keys = [], idx = -1, lastFocus = null, overlay = null, box = null, req = 0;
+
+    function esc( s ) {
+        return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
+    }
+    function g( n ) { return typeof window.clusterlaneGlyph === 'function' ? window.clusterlaneGlyph( n ) : ''; }
+    function tip( k ) { return typeof window.clusterlaneTip === 'function' ? window.clusterlaneTip( k ) : ''; }
+    function thumb( a, size ) { return typeof window.clusterlaneThumb === 'function' ? window.clusterlaneThumb( a.thumb || '', a.title, a.role, size ) : ''; }
+    function num( n ) { return Number( n || 0 ).toLocaleString(); }
+    function plural( n, one, many ) { return num( n ) + ' ' + ( +n === 1 ? one : many ); }
+    var ROLES = [ [ 'pillar', 'Pillar' ], [ 'support', 'Support' ], [ 'light', 'Light' ], [ 'orphan', 'Orphan' ] ];
+
+    function band( h, b ) { return h >= b.green ? 'good' : h >= b.orange ? 'fair' : 'weak'; }
+    function shortDate( ymd ) {
+        var p = String( ymd ).split( '-' );
+        var d = new Date( +p[ 0 ], +p[ 1 ] - 1, +p[ 2 ] );
+        return isNaN( d ) ? ymd : d.toLocaleDateString( undefined, { day: 'numeric', month: 'short' } );
+    }
+
+    /* ---------- the dialog ---------- */
+
+    function build() {
+        if ( overlay ) return;
+        overlay = document.createElement( 'div' );
+        overlay.className = 'clusterlane-cq-overlay';
+        overlay.hidden = true;
+        overlay.innerHTML =
+            '<div class="clusterlane-cq" role="dialog" aria-modal="true" aria-labelledby="clusterlane-cq-title" tabindex="-1">'
+          +   '<header class="clusterlane-cq__head">'
+          +     '<div class="clusterlane-cq__headtext"><div class="clusterlane-cq__kicker" data-cq="kicker"></div><h2 class="clusterlane-cq__title" id="clusterlane-cq-title" data-cq="title"></h2></div>'
+          +     '<div class="clusterlane-cq__tools">'
+          +       '<span class="clusterlane-cq__pos" data-cq="pos"></span>'
+          +       '<button type="button" class="clusterlane-cq__navbtn" data-cq-step="-1" aria-label="Previous cluster" title="Previous cluster (←)">‹</button>'
+          +       '<button type="button" class="clusterlane-cq__navbtn" data-cq-step="1" aria-label="Next cluster" title="Next cluster (→)">›</button>'
+          +       '<button type="button" class="clusterlane-cq__close" data-cq-close aria-label="Close">×</button>'
+          +     '</div>'
+          +   '</header>'
+          +   '<div class="clusterlane-cq__body" data-cq="body"></div>'
+          +   '<footer class="clusterlane-cq__foot">'
+          +     '<span class="clusterlane-cq__keys" aria-hidden="true"><kbd>←</kbd><kbd>→</kbd> other clusters · <kbd>Esc</kbd> close</span>'
+          +     '<a class="clusterlane-cq__open" data-cq="open" href="#">Open in Cluster Inspector' + g( 'arrow' ) + '</a>'
+          +   '</footer>'
+          + '</div>';
+        document.body.appendChild( overlay );
+        box = overlay.querySelector( '.clusterlane-cq' );
+
+        overlay.addEventListener( 'click', function ( e ) {
+            if ( e.target === overlay || e.target.closest( '[data-cq-close]' ) ) { close(); return; }
+            var st = e.target.closest( '[data-cq-step]' );
+            if ( st ) { step( +st.getAttribute( 'data-cq-step' ) ); return; }
+            var open = e.target.closest( '[data-cq="open"]' );
+            if ( open && typeof window.clusterlaneClusterQuickOpen === 'function' ) {
+                e.preventDefault();
+                var k = keys[ idx ];
+                close();
+                window.clusterlaneClusterQuickOpen( k );
+            }
+        } );
+        overlay.addEventListener( 'keydown', function ( e ) {
+            if ( e.key === 'Escape' ) { e.preventDefault(); close(); return; }
+            if ( ( e.key === 'ArrowLeft' || e.key === 'ArrowRight' ) && ! e.target.closest( 'input,textarea,select' ) ) {
+                e.preventDefault(); step( e.key === 'ArrowLeft' ? -1 : 1 ); return;
+            }
+            if ( e.key === 'Tab' ) { // keep focus inside the dialog
+                var f = [].slice.call( box.querySelectorAll( 'a[href],button:not([disabled]),[tabindex="0"]' ) ).filter( function ( el ) { return el.offsetParent !== null; } );
+                if ( ! f.length ) return;
+                var first = f[ 0 ], last = f[ f.length - 1 ];
+                if ( e.shiftKey && document.activeElement === first ) { e.preventDefault(); last.focus(); }
+                else if ( ! e.shiftKey && document.activeElement === last ) { e.preventDefault(); first.focus(); }
+            }
+        } );
+    }
+
+    function q( name ) { return box.querySelector( '[data-cq="' + name + '"]' ); }
+
+    function open( key, list ) {
+        build();
+        keys = list && list.length ? list : [ key ];
+        idx  = Math.max( 0, keys.indexOf( key ) );
+        if ( overlay.hidden ) {
+            lastFocus = document.activeElement;
+            overlay.hidden = false;
+            document.documentElement.classList.add( 'clusterlane-cq-lock' );
+            box.focus();
+        }
+        show();
+    }
+
+    function close() {
+        if ( ! overlay || overlay.hidden ) return;
+        overlay.hidden = true;
+        document.documentElement.classList.remove( 'clusterlane-cq-lock' );
+        if ( lastFocus && document.body.contains( lastFocus ) ) lastFocus.focus();
+    }
+
+    function step( d ) {
+        if ( keys.length < 2 ) return;
+        idx = ( idx + d + keys.length ) % keys.length;
+        show();
+    }
+
+    function show() {
+        var key = keys[ idx ];
+        var many = keys.length > 1;
+        q( 'pos' ).textContent = many ? ( idx + 1 ) + ' / ' + keys.length : '';
+        box.querySelectorAll( '[data-cq-step]' ).forEach( function ( b ) { b.hidden = ! many; } );
+        box.querySelector( '.clusterlane-cq__keys' ).innerHTML = ( many ? '<kbd>←</kbd><kbd>→</kbd> other clusters · ' : '' ) + '<kbd>Esc</kbd> close';
+        q( 'open' ).href = ( D.inspectorUrl || '#' ) + '&open=' + encodeURIComponent( key );
+        q( 'open' ).innerHTML = ( typeof window.clusterlaneClusterQuickOpen === 'function' ? 'Open cluster' : 'Open in Cluster Inspector' ) + g( 'arrow' );
+
+        var body = q( 'body' );
+        body.classList.remove( 'is-loading' );
+        if ( cache[ key ] ) { render( cache[ key ] ); prefetch(); return; }
+        var label = '';
+        var src = document.querySelector( '[data-clusterlane-quick="' + ( window.CSS && CSS.escape ? CSS.escape( key ) : key ) + '"]' );
+        if ( src ) label = src.getAttribute( 'data-clusterlane-quick-label' ) || '';
+        q( 'title' ).textContent = label || 'Loading…';
+        // Stepping to the next cluster (0.98.201): the previous one stays in place, dimmed, until the
+        // new one is here — no jump to a short loading box and back. The first open shows the spinner.
+        q( 'kicker' ).innerHTML = '&nbsp;';
+        if ( body.querySelector( '.clusterlane-cq__top' ) ) {
+            body.classList.add( 'is-loading' );
+        } else {
+            body.innerHTML = '<div class="clusterlane-cq__loading" role="status"><span class="clusterlane-cq__spin" aria-hidden="true"></span>Loading the cluster…</div>';
+        }
+
+        var my = ++req;
+        load( key ).then( function ( d ) {
+            if ( my !== req ) return;
+            body.classList.remove( 'is-loading' );
+            render( d );
+            prefetch();
+        } ).catch( function ( err ) {
+            if ( my !== req ) return;
+            body.classList.remove( 'is-loading' );
+            q( 'kicker' ).innerHTML = '&nbsp;';
+            q( 'title' ).textContent = label || key;
+            body.innerHTML = '<p class="clusterlane-cq__error">' + g( 'alert' ) + esc( err.message || 'Could not load the cluster.' ) + '</p>';
+        } );
+    }
+
+    var inflight = {};
+    function load( key ) {
+        if ( cache[ key ] ) return Promise.resolve( cache[ key ] );
+        if ( inflight[ key ] ) return inflight[ key ];
+        inflight[ key ] = fetch( D.rest + 'cluster-inspector/quick/' + encodeURIComponent( key ), { headers: { 'X-WP-Nonce': D.nonce }, credentials: 'same-origin' } )
+            .then( function ( r ) { return r.json().then( function ( j ) { return { ok: r.ok, j: j }; } ); } )
+            .then( function ( res ) {
+                delete inflight[ key ];
+                if ( ! res.ok || ! res.j || ! res.j.cluster ) throw new Error( ( res.j && ( res.j.error || res.j.message ) ) || 'Could not load the cluster.' );
+                cache[ key ] = res.j;
+                return res.j;
+            }, function ( err ) { delete inflight[ key ]; throw err; } );
+        return inflight[ key ];
+    }
+
+    // The clusters either side are fetched in the background, so ← / → is instant.
+    function prefetch() {
+        if ( keys.length < 2 ) return;
+        [ 1, -1 ].forEach( function ( d ) {
+            var k = keys[ ( idx + d + keys.length ) % keys.length ];
+            if ( ! cache[ k ] ) load( k ).catch( function () {} );
+        } );
+    }
+
+    /* ---------- content ---------- */
+
+    function spark( hist, b ) {
+        if ( ! hist || hist.length < 2 ) {
+            return '<p class="clusterlane-cq__nohist">The trend shows once a few days of history have been recorded.</p>';
+        }
+        var W = 240, H = 46, P = 4;
+        var vals = hist.map( function ( p ) { return +p.h; } );
+        var lo = Math.min.apply( null, vals ), hi = Math.max.apply( null, vals );
+        if ( hi - lo < 10 ) { var mid = ( hi + lo ) / 2; lo = mid - 5; hi = mid + 5; } // small changes stay small
+        var x = function ( i ) { return P + i * ( W - 2 * P ) / ( vals.length - 1 ); };
+        var y = function ( v ) { return H - P - ( v - lo ) * ( H - 2 * P ) / ( hi - lo ); };
+        var pts = vals.map( function ( v, i ) { return x( i ).toFixed( 1 ) + ',' + y( v ).toFixed( 1 ); } );
+        var area = 'M' + x( 0 ).toFixed( 1 ) + ',' + H + ' L' + pts.join( ' L' ) + ' L' + x( vals.length - 1 ).toFixed( 1 ) + ',' + H + ' Z';
+        var last = vals[ vals.length - 1 ];
+        var cls = 'clusterlane-cq__spark--' + band( last, b );
+        var label = 'Health ' + vals[ 0 ] + '% on ' + shortDate( hist[ 0 ].d ) + ', ' + last + '% today';
+        return '<svg class="clusterlane-cq__spark ' + cls + '" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" role="img" aria-label="' + esc( label ) + '">'
+             + '<path class="clusterlane-cq__spark-area" d="' + area + '"/>'
+             + '<polyline class="clusterlane-cq__spark-line" points="' + pts.join( ' ' ) + '" vector-effect="non-scaling-stroke"/>'
+             + '</svg>'
+             + '<div class="clusterlane-cq__sparkaxis"><span>' + esc( shortDate( hist[ 0 ].d ) ) + '</span><span>today</span></div>';
+    }
+
+    function render( d ) {
+        var c = d.cluster, b = d.bands || { green: 70, orange: 50 };
+        var n = +c.members, h = +c.health, r = d.roles || {}, L = d.links || {};
+        var hb = n ? band( h, b ) : 'none';
+
+        q( 'kicker' ).innerHTML = g( c.type_glyph || 'folder' ) + esc( c.type_label || c.type )
+            + ( c.archetype && c.archetype !== 'Unclassified' ? '<span class="clusterlane-cq__dot">·</span>' + esc( c.archetype ) + tip( 'archetype' ) : '' );
+        q( 'title' ).textContent = c.label_plain || c.label;
+
+        // Health + trend
+        var hist = d.history || [];
+        var delta = hist.length > 1 ? h - +hist[ 0 ].h : null;
+        var deltaHtml = delta === null ? ''
+            : '<span class="clusterlane-cq__delta clusterlane-cq__delta--' + ( delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat' ) + '">'
+              + ( delta > 0 ? '+' : delta < 0 ? '−' : '±' ) + Math.abs( delta ) + ' pts</span><span class="clusterlane-cq__since">since ' + esc( shortDate( hist[ 0 ].d ) ) + '</span>';
+        var healthCard = '<div class="clusterlane-cq__health clusterlane-cq__health--' + hb + '">'
+            + '<div class="clusterlane-cq__label">Health' + tip( 'health' ) + '</div>'
+            + '<div class="clusterlane-cq__hrow"><span class="clusterlane-cq__hval">' + ( n ? h + '%' : '—' ) + '</span>' + deltaHtml + '</div>'
+            + spark( hist, b )
+            + '</div>';
+
+        var orph = +r.orphan || 0;
+        var tiles = [
+            [ 'Articles', num( n ), '', d.scanned < n ? num( n - d.scanned ) + ' not scanned yet' : '' ],
+            [ 'Avg. article score', d.avg_score === null ? '—' : d.avg_score + '%', d.avg_score === null ? '' : 'is-' + band( d.avg_score, { green: 70, orange: 50 } ), '' ],
+            [ 'Links inside', num( L.inside ), '', n > 1 ? ( ( L.inside || 0 ) / n ).toFixed( 1 ).replace( /\.0$/, '' ) + ' per article' : '' ],
+            [ 'Orphans' + tip( 'orphans' ), num( orph ), orph ? 'is-weak' : 'is-good', orph ? '' : 'every article is linked' ],
+        ].map( function ( t ) {
+            return '<div class="clusterlane-cq__tile"><div class="clusterlane-cq__label">' + t[ 0 ] + '</div><div class="clusterlane-cq__tval ' + t[ 2 ] + '">' + t[ 1 ] + '</div>'
+                 + ( t[ 3 ] ? '<div class="clusterlane-cq__tsub">' + esc( t[ 3 ] ) + '</div>' : '' ) + '</div>';
+        } ).join( '' );
+
+        var focus = d.focus
+            ? '<div class="clusterlane-cq__focus">' + g( 'target' ) + '<div><strong>Start here</strong> ' + esc( d.focus ) + '</div></div>'
+            : ( n ? '<div class="clusterlane-cq__focus clusterlane-cq__focus--ok">' + g( 'check' ) + '<div><strong>In good shape</strong> Nothing urgent in this cluster.</div></div>' : '' );
+
+        // Role mix
+        var total = ROLES.reduce( function ( s, x ) { return s + ( +r[ x[ 0 ] ] || 0 ); }, 0 );
+        var mix = total
+            ? '<div class="clusterlane-cq__mix" role="img" aria-label="' + esc( ROLES.map( function ( x ) { return x[ 1 ] + ' ' + ( +r[ x[ 0 ] ] || 0 ); } ).join( ', ' ) ) + '">'
+              + ROLES.filter( function ( x ) { return +r[ x[ 0 ] ]; } ).map( function ( x ) { return '<span class="clusterlane-cq__seg clusterlane-cq__seg--' + x[ 0 ] + '" style="flex-grow:' + ( +r[ x[ 0 ] ] ) + '"></span>'; } ).join( '' )
+              + '</div><ul class="clusterlane-cq__legend">'
+              + ROLES.map( function ( x ) { return '<li' + ( +r[ x[ 0 ] ] ? '' : ' class="is-zero"' ) + '><i class="clusterlane-role-dot clusterlane-role-dot--' + x[ 0 ] + '"></i>' + x[ 1 ] + ' <b>' + num( r[ x[ 0 ] ] ) + '</b></li>'; } ).join( '' )
+              + '</ul>'
+            : '<p class="clusterlane-cq__muted">No articles scanned yet.</p>';
+
+        // Links
+        var hub = L.hub;
+        var linkRows = [
+            [ 'Between its articles', num( L.inside ) ],
+            [ 'Into it from the rest of the site', num( L.from_outside ), +L.from_outside === 0 && n > 1 ? 'is-weak' : '' ],
+            [ 'From it to the rest of the site', num( L.to_outside ) ],
+        ];
+        if ( hub ) linkRows.push( [ hub.url ? '<a href="' + esc( hub.url ) + '" target="_blank" rel="noopener">Archive page</a> linked from' : 'Archive page linked from', num( hub.linked ) + ' of ' + num( hub.of ), hub.linked ? '' : 'is-weak', true ] );
+        var links = '<dl class="clusterlane-cq__dl">' + linkRows.map( function ( x ) {
+            return '<div><dt>' + ( x[ 3 ] ? x[ 0 ] : esc( x[ 0 ] ) ) + '</dt><dd class="' + ( x[ 2 ] || '' ) + '">' + x[ 1 ] + '</dd></div>';
+        } ).join( '' ) + '</dl>';
+
+        // Meta descriptions
+        var m = d.meta || {};
+        var metaBad = ( +m.missing || 0 ) + ( +m.weak || 0 );
+        var meta = n
+            ? ( metaBad
+                ? '<p class="clusterlane-cq__meta">' + [ +m.missing ? '<span class="is-weak">' + plural( m.missing, 'missing', 'missing' ) + '</span>' : '', +m.weak ? '<span class="is-fair">' + plural( m.weak, 'too short or long', 'too short or long' ) + '</span>' : '' ].filter( Boolean ).join( ' · ' )
+                  + ' <a href="' + esc( d.urls.seo ) + '">Fix in SEO Titles' + g( 'arrow' ) + '</a></p>'
+                : '<p class="clusterlane-cq__meta clusterlane-cq__meta--ok">' + g( 'check' ) + 'Every article has a good meta description.</p>' )
+            : '';
+
+        // Pillar
+        var pil = ( d.pillars || [] );
+        var pillar = pil.length
+            ? pil.map( function ( a ) {
+                return '<a class="clusterlane-cq__art" href="' + esc( a.url ) + '">' + thumb( a, 'md' )
+                     + '<span class="clusterlane-cq__arttext"><span class="clusterlane-cq__artname">' + esc( a.title ) + '</span>'
+                     + '<span class="clusterlane-cq__artwhy">' + ( a.chosen ? 'Chosen pillar' : 'Pillar' ) + ' · ' + plural( a.inbound, 'link in', 'links in' ) + ( a.score !== null ? ' · score ' + a.score + '%' : '' ) + '</span></span></a>';
+            } ).join( '' )
+            : '<p class="clusterlane-cq__muted">No pillar yet' + ( n >= 4 ? ' — choose one in Cluster Inspector, or link the strongest article from its siblings.' : '.' ) + '</p>';
+
+        // Start with these
+        var att = d.attention || [];
+        var start = att.length
+            ? '<div class="clusterlane-cq__list">' + att.map( function ( a ) {
+                return '<a class="clusterlane-cq__art" href="' + esc( a.url ) + '">' + thumb( a, 'sm' )
+                     + '<span class="clusterlane-cq__arttext"><span class="clusterlane-cq__artname">' + esc( a.title ) + '</span>'
+                     + '<span class="clusterlane-cq__artwhy"><i class="clusterlane-role-dot clusterlane-role-dot--' + esc( a.role ) + '"></i>' + esc( a.why ) + ( a.score !== null ? ' · score ' + a.score + '%' : '' ) + '</span></span>'
+                     + '<span class="clusterlane-cq__go" aria-hidden="true">' + g( 'arrow' ) + '</span></a>';
+            } ).join( '' ) + '</div>'
+              + ( d.attention_total > att.length ? '<p class="clusterlane-cq__more">+ ' + plural( d.attention_total - att.length, 'more article', 'more articles' ) + ' in Cluster Inspector</p>' : '' )
+            : '<p class="clusterlane-cq__muted">' + ( n ? 'No orphans and no weak articles.' : 'This cluster has no articles.' ) + '</p>';
+
+        q( 'body' ).innerHTML =
+              '<div class="clusterlane-cq__top">' + healthCard + '<div class="clusterlane-cq__tiles">' + tiles + '</div></div>'
+            + focus
+            + '<div class="clusterlane-cq__cols">'
+            +   '<div class="clusterlane-cq__col">'
+            +     '<section class="clusterlane-cq__sec"><h3>Role mix' + tip( 'role' ) + '</h3>' + mix + '</section>'
+            +     '<section class="clusterlane-cq__sec"><h3>Links</h3>' + links + '</section>'
+            +     ( meta ? '<section class="clusterlane-cq__sec"><h3>Meta descriptions</h3>' + meta + '</section>' : '' )
+            +   '</div>'
+            +   '<div class="clusterlane-cq__col">'
+            +     '<section class="clusterlane-cq__sec"><h3>Pillar' + tip( 'pillars' ) + '</h3>' + pillar + '</section>'
+            +     '<section class="clusterlane-cq__sec"><h3>Start with these</h3>' + start + '</section>'
+            +   '</div>'
+            + '</div>';
+        q( 'body' ).scrollTop = 0;
+    }
+
+    /* ---------- triggers ---------- */
+
+    document.addEventListener( 'click', function ( e ) {
+        var t = e.target.closest( '[data-clusterlane-quick]' );
+        if ( ! t || ( overlay && overlay.contains( t ) ) ) return;
+        e.preventDefault();
+        e.stopPropagation();
+        var list = [];
+        document.querySelectorAll( '[data-clusterlane-quick]' ).forEach( function ( el ) {
+            var k = el.getAttribute( 'data-clusterlane-quick' );
+            if ( k && list.indexOf( k ) < 0 ) list.push( k );
+        } );
+        open( t.getAttribute( 'data-clusterlane-quick' ), list );
+    }, true );
+
+    window.clusterlaneClusterQuick = { open: open, close: close };
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/clusterlane-edition.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/clusterlane-edition.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/clusterlane-edition.js	(revision 3751027)
@@ -0,0 +1,175 @@
+/* Clusterlane — edition helpers (Free / Pro / Agency).
+ *
+ * clusterlaneEditionData is localized from Edition::for_js() + ProExamples::data().
+ * Page scripts ask clusterlaneHas( 'ai' ) before calling an AI endpoint; when the
+ * feature is missing they render clusterlaneEditionData.examples.<x> through their
+ * normal renderer and put clusterlaneProBanner() above it.
+ */
+( function () {
+    'use strict';
+    var D = ( typeof clusterlaneEditionData !== 'undefined' ) ? clusterlaneEditionData : { features: {}, examples: {}, labels: {} };
+
+    /*
+     * Plain permalinks: rest_url() is "…/?rest_route=/clusterlane/v1/", so a call like
+     * REST + 'inspector/search?q=x' gets a second "?" and 404s. Normalise every
+     * such URL (only ones containing rest_route= with more than one "?") to
+     * use "&" for the extra parameters — for fetch() and XMLHttpRequest alike.
+     */
+    function fixRestUrl( url ) {
+        if ( typeof url !== 'string' || url.indexOf( 'rest_route=' ) === -1 ) return url;
+        var i = url.indexOf( '?' );
+        return i === -1 ? url : url.slice( 0, i + 1 ) + url.slice( i + 1 ).replace( /\?/g, '&' );
+    }
+    window.clusterlaneFixRestUrl = fixRestUrl;
+
+    /*
+     * Export CSV (0.98.166): clusterlaneCsv( 'clusterlane-articles', [ [ 'Title', r => r.title ], … ], rows ).
+     * Semicolons where the viewer's locale writes decimals with a comma (Swedish,
+     * German … Excel expects that), commas elsewhere. UTF-8 with BOM so Excel
+     * reads å, ä, ö. The date goes in the file name.
+     */
+    /*
+     * Big clusters (0.98.169): the graph endpoint draws at most 150 articles and
+     * says so in data.truncated — show that above the graph, or remove the note.
+     */
+    window.clusterlaneGraphNote = function ( el, data ) {
+        if ( ! el || ! el.parentNode ) return;
+        var box = el.parentNode.querySelector( ':scope > .clusterlane-graph-trunc' );
+        var t   = data && data.truncated;
+        if ( ! t ) { if ( box ) box.remove(); return; }
+        if ( ! box ) { box = document.createElement( 'p' ); box.className = 'clusterlane-graph-trunc'; el.parentNode.insertBefore( box, el ); }
+        box.textContent = 'Showing the ' + t.shown + ' most important of ' + Number( t.total ).toLocaleString() + ' articles — pillars, the best-linked and some orphans. The full list is in the article table.';
+    };
+
+    /*
+     * Export CSV (0.98.169): fetch every page of an export=1 list, 1,000 rows at a
+     * time, showing progress on the button. fetchPage( page ) returns the JSON.
+     */
+    window.clusterlaneExportAll = async function ( fetchPage, btn ) {
+        var label = btn ? btn.innerHTML : '', items = [], page = 1, pages = 1;
+        try {
+            do {
+                if ( btn && pages > 1 ) btn.lastChild.textContent = 'Exporting ' + page + ' / ' + pages + '…';
+                var data = await fetchPage( page );
+                items = items.concat( data.items || [] );
+                pages = data.pages || 1;
+                page++;
+            } while ( page <= pages );
+        } finally { if ( btn ) btn.innerHTML = label; }
+        return items;
+    };
+
+    window.clusterlaneCsv = function ( name, columns, rows ) {
+        var sep = ( 1.5 ).toLocaleString().indexOf( ',' ) !== -1 ? ';' : ',';
+        var cell = function ( v ) {
+            if ( v === null || v === undefined ) v = '';
+            v = String( v );
+            if ( /^[=+\-@]/.test( v ) && ! /^-?\d+(\.\d+)?$/.test( v ) ) v = "'" + v; // no formulas from article text
+            return /["\r\n;,]/.test( v ) ? '"' + v.replace( /"/g, '""' ) + '"' : v;
+        };
+        var lines = [ columns.map( function ( c ) { return cell( c[0] ); } ).join( sep ) ];
+        rows.forEach( function ( r ) { lines.push( columns.map( function ( c ) { return cell( c[1]( r ) ); } ).join( sep ) ); } );
+        var blob = new Blob( [ '\ufeff' + lines.join( '\r\n' ) + '\r\n' ], { type: 'text/csv;charset=utf-8' } );
+        var a = document.createElement( 'a' );
+        var d = new Date();
+        var pad = function ( n ) { return ( n < 10 ? '0' : '' ) + n; };
+        a.href = URL.createObjectURL( blob );
+        a.download = name + '-' + d.getFullYear() + '-' + pad( d.getMonth() + 1 ) + '-' + pad( d.getDate() ) + '.csv';
+        document.body.appendChild( a );
+        a.click();
+        setTimeout( function () { URL.revokeObjectURL( a.href ); a.remove(); }, 1000 );
+        return rows.length;
+    };
+    if ( window.fetch && ! window.fetch.__clusterlanePatched ) {
+        var origFetch = window.fetch;
+        window.fetch = function ( input, init ) {
+            return origFetch.call( this, typeof input === 'string' ? fixRestUrl( input ) : input, init );
+        };
+        window.fetch.__clusterlanePatched = true;
+    }
+    if ( window.XMLHttpRequest && ! XMLHttpRequest.prototype.__clusterlanePatched ) {
+        var origOpen = XMLHttpRequest.prototype.open;
+        XMLHttpRequest.prototype.open = function ( method, url ) {
+            var args = Array.prototype.slice.call( arguments );
+            args[1] = fixRestUrl( url );
+            return origOpen.apply( this, args );
+        };
+        XMLHttpRequest.prototype.__clusterlanePatched = true;
+    }
+
+    function esc( s ) {
+        return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
+    }
+
+    /** Feature available in this edition? Unknown features default to available. */
+    window.clusterlaneHas = function ( feature ) {
+        return ! ( feature in ( D.features || {} ) ) || !! D.features[ feature ];
+    };
+
+    /**
+     * May the current user write this through Clusterlane? 'links' (add or
+     * remove links in article text) or 'seo' (SEO title / description).
+     * Set per role in Settings → General → Access (0.98.132); the server
+     * checks the same. Missing data = allowed (older cached page).
+     */
+    window.clusterlaneCan = function ( what ) {
+        var c = D.can || {};
+        return ! ( what in c ) || !! c[ what ];
+    };
+
+    /** A one-line "your role can look but not write" note, or '' when allowed. */
+    window.clusterlaneNoRightNote = function ( what ) {
+        if ( window.clusterlaneCan( what ) ) return '';
+        var t = ( D.can || {} )[ what + 'Note' ] || '';
+        return t ? '<p class="clusterlane-no-right">' + esc( t ) + '</p>' : '';
+    };
+
+    /** Live AI handlers loaded (Pro code present) and allowed by the edition? */
+    window.clusterlaneProReady = function () {
+        return !! window.clusterlanePro && window.clusterlaneHas( 'ai' );
+    };
+
+    /** Example data for a feature (same shape as the real REST response). */
+    window.clusterlaneProExample = function ( key ) {
+        return ( D.examples || {} )[ key ] || null;
+    };
+
+    /** Banner shown above an example: clearly an example, for a demo site. */
+    window.clusterlaneProBanner = function ( featureLabel ) {
+        var L = D.labels || {};
+        return '<div class="clusterlane-pro-example-banner" role="note">'
+            + '<span class="clusterlane-pro-badge">' + esc( L.pro || 'PRO' ) + '</span>'
+            + '<span class="clusterlane-pro-example-text"><strong>' + esc( L.example || 'Example' ) + '</strong> — '
+            + esc( ( L.bannerText || '%1$s for a demo site (%2$s). Clusterlane Pro does the same for your own clusters.' )
+                .replace( '%1$s', featureLabel ).replace( '%2$s', ( D.examples || {} ).site || '' ) )
+            + '</span>'
+            + ( D.upgradeUrl ? ' <a class="clusterlane-pro-example-link" href="' + esc( D.upgradeUrl ) + '" target="_blank" rel="noopener">' + esc( L.seePro || 'See Pro →' ) + '</a>' : '' )
+            + '</div>';
+    };
+
+    /** Disable every button/link-action inside an example so nothing is sent. */
+    window.clusterlaneFreezeExample = function ( root ) {
+        if ( ! root ) return;
+        root.classList.add( 'clusterlane-pro-example' );
+        root.querySelectorAll( 'button, select, input, textarea' ).forEach( function ( el ) {
+            el.disabled = true;
+            el.title = ( D.labels || {} ).exampleOnly || 'Example only';
+        } );
+    };
+} )();
+
+/* Cluster types (0.98.177): categories, tags, product categories, custom
+   clusters and any other taxonomy (ClusterTypes::for_js → window.clusterlaneClusterTypes).
+   Unknown types fall back to the taxonomy name with a tag glyph. */
+window.clusterlaneType = function ( t ) {
+    var map = window.clusterlaneClusterTypes || {};
+    var d = { category: [ 'Category', 'Categories', 'folder', 'article' ], post_tag: [ 'Tag', 'Tags', 'tag', 'article' ], custom: [ 'Custom', 'Custom clusters', 'pencil', 'article' ], product_cat: [ 'Product category', 'Product categories', 'bag', 'product' ] }[ t ];
+    var m = map[ t ];
+    if ( m ) return { label: m.label, plural: m.plural, glyph: m.glyph, noun: m.noun };
+    if ( d ) return { label: d[0], plural: d[1], glyph: d[2], noun: d[3] };
+    var name = String( t || 'Cluster' ).replace( /[_-]+/g, ' ' );
+    return { label: name.charAt( 0 ).toUpperCase() + name.slice( 1 ), plural: name, glyph: 'tag', noun: 'article' };
+};
+
+/* Brand (white label, see Brand.php): the name to show instead of "Clusterlane". */
+window.clusterlaneBrandName = function () { return ( window.clusterlaneBrand && window.clusterlaneBrand.name ) || 'Clusterlane'; };
Index: /clusterlane/tags/1.3.2/admin/js/clusterlane-graph-lib.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/clusterlane-graph-lib.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/clusterlane-graph-lib.js	(revision 3751027)
@@ -0,0 +1,51 @@
+/**
+ * vis-network
+ * https://visjs.github.io/vis-network/
+ *
+ * A dynamic, browser-based visualization library.
+ *
+ * @version 9.1.2
+ * @date    2022-03-28T20:13:51.046Z
+ *
+ * @copyright (c) 2011-2017 Almende B.V, http://almende.com
+ * @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs
+ *
+ * @license
+ * vis.js is dual licensed under both
+ *
+ *   1. The Apache 2.0 License
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *   and
+ *
+ *   2. The MIT License
+ *      http://opensource.org/licenses/MIT
+ *
+ * vis.js may be distributed under either license.
+ */
+!function(g,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((g="undefined"!=typeof globalThis?globalThis:g||self).vis=g.vis||{})}(this,(function(g){var t="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},A=function(g){return g&&g.Math==Math&&g},e=A("object"==typeof globalThis&&globalThis)||A("object"==typeof window&&window)||A("object"==typeof self&&self)||A("object"==typeof t&&t)||function(){return this}()||Function("return this")(),C=function(g){try{return!!g()}catch(g){return!0}},I=!C((function(){var g=function(){}.bind();return"function"!=typeof g||g.hasOwnProperty("prototype")})),i=I,o=Function.prototype,n=o.apply,r=o.call,s="object"==typeof Reflect&&Reflect.apply||(i?r.bind(n):function(){return r.apply(n,arguments)}),a=I,d=Function.prototype,h=d.bind,l=d.call,c=a&&h.bind(l,l),u=a?function(g){return g&&c(g)}:function(g){return g&&function(){return l.apply(g,arguments)}},p=function(g){return"function"==typeof g},f={},v=!C((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),y=I,m=Function.prototype.call,b=y?m.bind(m):function(){return m.apply(m,arguments)},w={},k={}.propertyIsEnumerable,x=Object.getOwnPropertyDescriptor,E=x&&!k.call({1:2},1);w.f=E?function(g){var t=x(this,g);return!!t&&t.enumerable}:k;var O,T,D=function(g,t){return{enumerable:!(1&g),configurable:!(2&g),writable:!(4&g),value:t}},N=u,R=N({}.toString),P=N("".slice),M=function(g){return P(R(g),8,-1)},B=u,z=C,Z=M,S=e.Object,F=B("".split),G=z((function(){return!S("z").propertyIsEnumerable(0)}))?function(g){return"String"==Z(g)?F(g,""):S(g)}:S,L=e.TypeError,j=function(g){if(null==g)throw L("Can't call method on "+g);return g},V=G,Y=j,W=function(g){return V(Y(g))},Q=p,_=function(g){return"object"==typeof g?null!==g:Q(g)},U={},H=U,K=e,X=p,J=function(g){return X(g)?g:void 0},q=function(g,t){return arguments.length<2?J(H[g])||J(K[g]):H[g]&&H[g][t]||K[g]&&K[g][t]},$=u({}.isPrototypeOf),gg=q("navigator","userAgent")||"",tg=e,Ag=gg,eg=tg.process,Cg=tg.Deno,Ig=eg&&eg.versions||Cg&&Cg.version,ig=Ig&&Ig.v8;ig&&(T=(O=ig.split("."))[0]>0&&O[0]<4?1:+(O[0]+O[1])),!T&&Ag&&(!(O=Ag.match(/Edge\/(\d+)/))||O[1]>=74)&&(O=Ag.match(/Chrome\/(\d+)/))&&(T=+O[1]);var og=T,ng=og,rg=C,sg=!!Object.getOwnPropertySymbols&&!rg((function(){var g=Symbol();return!String(g)||!(Object(g)instanceof Symbol)||!Symbol.sham&&ng&&ng<41})),ag=sg&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,dg=q,hg=p,lg=$,cg=ag,ug=e.Object,pg=cg?function(g){return"symbol"==typeof g}:function(g){var t=dg("Symbol");return hg(t)&&lg(t.prototype,ug(g))},fg=e.String,vg=function(g){try{return fg(g)}catch(g){return"Object"}},yg=p,mg=vg,bg=e.TypeError,wg=function(g){if(yg(g))return g;throw bg(mg(g)+" is not a function")},kg=wg,xg=function(g,t){var A=g[t];return null==A?void 0:kg(A)},Eg=b,Og=p,Tg=_,Dg=e.TypeError,Ng={exports:{}},Rg=e,Pg=Object.defineProperty,Mg=function(g,t){try{Pg(Rg,g,{value:t,configurable:!0,writable:!0})}catch(A){Rg[g]=t}return t},Bg="__core-js_shared__",zg=e[Bg]||Mg(Bg,{}),Zg=zg;(Ng.exports=function(g,t){return Zg[g]||(Zg[g]=void 0!==t?t:{})})("versions",[]).push({version:"3.21.1",mode:"pure",copyright:"© 2014-2022 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.21.1/LICENSE",source:"https://github.com/zloirock/core-js"});var Sg=j,Fg=e.Object,Gg=function(g){return Fg(Sg(g))},Lg=Gg,jg=u({}.hasOwnProperty),Vg=Object.hasOwn||function(g,t){return jg(Lg(g),t)},Yg=u,Wg=0,Qg=Math.random(),_g=Yg(1..toString),Ug=function(g){return"Symbol("+(void 0===g?"":g)+")_"+_g(++Wg+Qg,36)},Hg=e,Kg=Ng.exports,Xg=Vg,Jg=Ug,qg=sg,$g=ag,gt=Kg("wks"),tt=Hg.Symbol,At=tt&&tt.for,et=$g?tt:tt&&tt.withoutSetter||Jg,Ct=function(g){if(!Xg(gt,g)||!qg&&"string"!=typeof gt[g]){var t="Symbol."+g;qg&&Xg(tt,g)?gt[g]=tt[g]:gt[g]=$g&&At?At(t):et(t)}return gt[g]},It=b,it=_,ot=pg,nt=xg,rt=function(g,t){var A,e;if("string"===t&&Og(A=g.toString)&&!Tg(e=Eg(A,g)))return e;if(Og(A=g.valueOf)&&!Tg(e=Eg(A,g)))return e;if("string"!==t&&Og(A=g.toString)&&!Tg(e=Eg(A,g)))return e;throw Dg("Can't convert object to primitive value")},st=Ct,at=e.TypeError,dt=st("toPrimitive"),ht=function(g,t){if(!it(g)||ot(g))return g;var A,e=nt(g,dt);if(e){if(void 0===t&&(t="default"),A=It(e,g,t),!it(A)||ot(A))return A;throw at("Can't convert object to primitive value")}return void 0===t&&(t="number"),rt(g,t)},lt=pg,ct=function(g){var t=ht(g,"string");return lt(t)?t:t+""},ut=_,pt=e.document,ft=ut(pt)&&ut(pt.createElement),vt=function(g){return ft?pt.createElement(g):{}},yt=vt,mt=!v&&!C((function(){return 7!=Object.defineProperty(yt("div"),"a",{get:function(){return 7}}).a})),bt=v,wt=b,kt=w,xt=D,Et=W,Ot=ct,Tt=Vg,Dt=mt,Nt=Object.getOwnPropertyDescriptor;f.f=bt?Nt:function(g,t){if(g=Et(g),t=Ot(t),Dt)try{return Nt(g,t)}catch(g){}if(Tt(g,t))return xt(!wt(kt.f,g,t),g[t])};var Rt=C,Pt=p,Mt=/#|\.prototype\./,Bt=function(g,t){var A=Zt[zt(g)];return A==Ft||A!=St&&(Pt(t)?Rt(t):!!t)},zt=Bt.normalize=function(g){return String(g).replace(Mt,".").toLowerCase()},Zt=Bt.data={},St=Bt.NATIVE="N",Ft=Bt.POLYFILL="P",Gt=Bt,Lt=wg,jt=I,Vt=u(u.bind),Yt=function(g,t){return Lt(g),void 0===t?g:jt?Vt(g,t):function(){return g.apply(t,arguments)}},Wt={},Qt=v&&C((function(){return 42!=Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype})),_t=e,Ut=_,Ht=_t.String,Kt=_t.TypeError,Xt=function(g){if(Ut(g))return g;throw Kt(Ht(g)+" is not an object")},Jt=v,qt=mt,$t=Qt,gA=Xt,tA=ct,AA=e.TypeError,eA=Object.defineProperty,CA=Object.getOwnPropertyDescriptor,IA="enumerable",iA="configurable",oA="writable";Wt.f=Jt?$t?function(g,t,A){if(gA(g),t=tA(t),gA(A),"function"==typeof g&&"prototype"===t&&"value"in A&&oA in A&&!A.writable){var e=CA(g,t);e&&e.writable&&(g[t]=A.value,A={configurable:iA in A?A.configurable:e.configurable,enumerable:IA in A?A.enumerable:e.enumerable,writable:!1})}return eA(g,t,A)}:eA:function(g,t,A){if(gA(g),t=tA(t),gA(A),qt)try{return eA(g,t,A)}catch(g){}if("get"in A||"set"in A)throw AA("Accessors not supported");return"value"in A&&(g[t]=A.value),g};var nA=Wt,rA=D,sA=v?function(g,t,A){return nA.f(g,t,rA(1,A))}:function(g,t,A){return g[t]=A,g},aA=e,dA=s,hA=u,lA=p,cA=f.f,uA=Gt,pA=U,fA=Yt,vA=sA,yA=Vg,mA=function(g){var t=function(A,e,C){if(this instanceof t){switch(arguments.length){case 0:return new g;case 1:return new g(A);case 2:return new g(A,e)}return new g(A,e,C)}return dA(g,this,arguments)};return t.prototype=g.prototype,t},bA=function(g,t){var A,e,C,I,i,o,n,r,s=g.target,a=g.global,d=g.stat,h=g.proto,l=a?aA:d?aA[s]:(aA[s]||{}).prototype,c=a?pA:pA[s]||vA(pA,s,{})[s],u=c.prototype;for(C in t)A=!uA(a?C:s+(d?".":"#")+C,g.forced)&&l&&yA(l,C),i=c[C],A&&(o=g.noTargetGet?(r=cA(l,C))&&r.value:l[C]),I=A&&o?o:t[C],A&&typeof i==typeof I||(n=g.bind&&A?fA(I,aA):g.wrap&&A?mA(I):h&&lA(I)?hA(I):I,(g.sham||I&&I.sham||i&&i.sham)&&vA(n,"sham",!0),vA(c,C,n),h&&(yA(pA,e=s+"Prototype")||vA(pA,e,{}),vA(pA[e],C,I),g.real&&u&&!u[C]&&vA(u,C,I)))},wA=Math.ceil,kA=Math.floor,xA=function(g){var t=+g;return t!=t||0===t?0:(t>0?kA:wA)(t)},EA=xA,OA=Math.max,TA=Math.min,DA=function(g,t){var A=EA(g);return A<0?OA(A+t,0):TA(A,t)},NA=xA,RA=Math.min,PA=function(g){return g>0?RA(NA(g),9007199254740991):0},MA=function(g){return PA(g.length)},BA=W,zA=DA,ZA=MA,SA=function(g){return function(t,A,e){var C,I=BA(t),i=ZA(I),o=zA(e,i);if(g&&A!=A){for(;i>o;)if((C=I[o++])!=C)return!0}else for(;i>o;o++)if((g||o in I)&&I[o]===A)return g||o||0;return!g&&-1}},FA={includes:SA(!0),indexOf:SA(!1)},GA={},LA=Vg,jA=W,VA=FA.indexOf,YA=GA,WA=u([].push),QA=function(g,t){var A,e=jA(g),C=0,I=[];for(A in e)!LA(YA,A)&&LA(e,A)&&WA(I,A);for(;t.length>C;)LA(e,A=t[C++])&&(~VA(I,A)||WA(I,A));return I},_A=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],UA=QA,HA=_A,KA=Object.keys||function(g){return UA(g,HA)},XA={};XA.f=Object.getOwnPropertySymbols;var JA=v,qA=u,$A=b,ge=C,te=KA,Ae=XA,ee=w,Ce=Gg,Ie=G,ie=Object.assign,oe=Object.defineProperty,ne=qA([].concat),re=!ie||ge((function(){if(JA&&1!==ie({b:1},ie(oe({},"a",{enumerable:!0,get:function(){oe(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var g={},t={},A=Symbol(),e="abcdefghijklmnopqrst";return g[A]=7,e.split("").forEach((function(g){t[g]=g})),7!=ie({},g)[A]||te(ie({},t)).join("")!=e}))?function(g,t){for(var A=Ce(g),e=arguments.length,C=1,I=Ae.f,i=ee.f;e>C;)for(var o,n=Ie(arguments[C++]),r=I?ne(te(n),I(n)):te(n),s=r.length,a=0;s>a;)o=r[a++],JA&&!$A(i,n,o)||(A[o]=n[o]);return A}:ie,se=re;bA({target:"Object",stat:!0,forced:Object.assign!==se},{assign:se});var ae=U.Object.assign,de=u([].slice),he=u,le=wg,ce=_,ue=Vg,pe=de,fe=I,ve=e.Function,ye=he([].concat),me=he([].join),be={},we=function(g,t,A){if(!ue(be,t)){for(var e=[],C=0;C<t;C++)e[C]="a["+C+"]";be[t]=ve("C,a","return new C("+me(e,",")+")")}return be[t](g,A)},ke=fe?ve.bind:function(g){var t=le(this),A=t.prototype,e=pe(arguments,1),C=function(){var A=ye(e,pe(arguments));return this instanceof C?we(t,A.length,A):t.apply(g,A)};return ce(A)&&(C.prototype=A),C},xe=ke;bA({target:"Function",proto:!0,forced:Function.bind!==xe},{bind:xe});var Ee=U,Oe=function(g){return Ee[g+"Prototype"]},Te=Oe("Function").bind,De=$,Ne=Te,Re=Function.prototype,Pe=function(g){var t=g.bind;return g===Re||De(Re,g)&&t===Re.bind?Ne:t},Me=Pe;function Be(g,t,A,e){g.beginPath(),g.arc(t,A,e,0,2*Math.PI,!1),g.closePath()}function ze(g,t,A,e,C,I){var i=Math.PI/180;e-2*I<0&&(I=e/2),C-2*I<0&&(I=C/2),g.beginPath(),g.moveTo(t+I,A),g.lineTo(t+e-I,A),g.arc(t+e-I,A+I,I,270*i,360*i,!1),g.lineTo(t+e,A+C-I),g.arc(t+e-I,A+C-I,I,0,90*i,!1),g.lineTo(t+I,A+C),g.arc(t+I,A+C-I,I,90*i,180*i,!1),g.lineTo(t,A+I),g.arc(t+I,A+I,I,180*i,270*i,!1),g.closePath()}function Ze(g,t,A,e,C){var I=.5522848,i=e/2*I,o=C/2*I,n=t+e,r=A+C,s=t+e/2,a=A+C/2;g.beginPath(),g.moveTo(t,a),g.bezierCurveTo(t,a-o,s-i,A,s,A),g.bezierCurveTo(s+i,A,n,a-o,n,a),g.bezierCurveTo(n,a+o,s+i,r,s,r),g.bezierCurveTo(s-i,r,t,a+o,t,a),g.closePath()}function Se(g,t,A,e,C){var I=C*(1/3),i=.5522848,o=e/2*i,n=I/2*i,r=t+e,s=A+I,a=t+e/2,d=A+I/2,h=A+(C-I/2),l=A+C;g.beginPath(),g.moveTo(r,d),g.bezierCurveTo(r,d+n,a+o,s,a,s),g.bezierCurveTo(a-o,s,t,d+n,t,d),g.bezierCurveTo(t,d-n,a-o,A,a,A),g.bezierCurveTo(a+o,A,r,d-n,r,d),g.lineTo(r,h),g.bezierCurveTo(r,h+n,a+o,l,a,l),g.bezierCurveTo(a-o,l,t,h+n,t,h),g.lineTo(t,d)}function Fe(g,t,A,e,C,I){g.beginPath(),g.moveTo(t,A);for(var i=I.length,o=e-t,n=C-A,r=n/o,s=Math.sqrt(o*o+n*n),a=0,d=!0,h=0,l=+I[0];s>=.1;)(l=+I[a++%i])>s&&(l=s),h=Math.sqrt(l*l/(1+r*r)),t+=h=o<0?-h:h,A+=r*h,!0===d?g.lineTo(t,A):g.moveTo(t,A),s-=l,d=!d}var Ge={circle:Be,dashedLine:Fe,database:Se,diamond:function(g,t,A,e){g.beginPath(),g.lineTo(t,A+e),g.lineTo(t+e,A),g.lineTo(t,A-e),g.lineTo(t-e,A),g.closePath()},ellipse:Ze,ellipse_vis:Ze,hexagon:function(g,t,A,e){g.beginPath();var C=2*Math.PI/6;g.moveTo(t+e,A);for(var I=1;I<6;I++)g.lineTo(t+e*Math.cos(C*I),A+e*Math.sin(C*I));g.closePath()},roundRect:ze,square:function(g,t,A,e){g.beginPath(),g.rect(t-e,A-e,2*e,2*e),g.closePath()},star:function(g,t,A,e){g.beginPath(),A+=.1*(e*=.82);for(var C=0;C<10;C++){var I=C%2==0?1.3*e:.5*e;g.lineTo(t+I*Math.sin(2*C*Math.PI/10),A-I*Math.cos(2*C*Math.PI/10))}g.closePath()},triangle:function(g,t,A,e){g.beginPath(),A+=.275*(e*=1.15);var C=2*e,I=C/2,i=Math.sqrt(3)/6*C,o=Math.sqrt(C*C-I*I);g.moveTo(t,A-(o-i)),g.lineTo(t+I,A+i),g.lineTo(t-I,A+i),g.lineTo(t,A-(o-i)),g.closePath()},triangleDown:function(g,t,A,e){g.beginPath(),A-=.275*(e*=1.15);var C=2*e,I=C/2,i=Math.sqrt(3)/6*C,o=Math.sqrt(C*C-I*I);g.moveTo(t,A+(o-i)),g.lineTo(t+I,A-i),g.lineTo(t-I,A-i),g.lineTo(t,A+(o-i)),g.closePath()}};function Le(g,t){void 0===t&&(t={});var A=t.insertAt;if(g&&"undefined"!=typeof document){var e=document.head||document.getElementsByTagName("head")[0],C=document.createElement("style");C.type="text/css","top"===A&&e.firstChild?e.insertBefore(C,e.firstChild):e.appendChild(C),C.styleSheet?C.styleSheet.cssText=g:C.appendChild(document.createTextNode(g))}}Le(".vis-overlay{bottom:0;left:0;position:absolute;right:0;top:0;z-index:10}.vis-active{box-shadow:0 0 10px #86d5f8}");Le(".vis [class*=span]{min-height:0;width:auto}");Le('div.vis-color-picker{background-color:#fff;border-radius:15px;box-shadow:0 0 10px 0 rgba(0,0,0,.5);display:none;height:444px;left:30px;margin-left:30px;margin-top:-140px;padding:10px;position:absolute;top:0;width:310px;z-index:1}div.vis-color-picker div.vis-arrow{left:5px;position:absolute;top:147px}div.vis-color-picker div.vis-arrow:after,div.vis-color-picker div.vis-arrow:before{border:solid transparent;content:" ";height:0;pointer-events:none;position:absolute;right:100%;top:50%;width:0}div.vis-color-picker div.vis-arrow:after{border-color:hsla(0,0%,100%,0) #fff hsla(0,0%,100%,0) hsla(0,0%,100%,0);border-width:30px;margin-top:-30px}div.vis-color-picker div.vis-color{cursor:pointer;height:289px;position:absolute;width:289px}div.vis-color-picker div.vis-brightness{position:absolute;top:313px}div.vis-color-picker div.vis-opacity{position:absolute;top:350px}div.vis-color-picker div.vis-selector{background:#4c4c4c;background:-moz-linear-gradient(top,#4c4c4c 0,#595959 12%,#666 25%,#474747 39%,#2c2c2c 50%,#000 51%,#111 60%,#2b2b2b 76%,#1c1c1c 91%,#131313 100%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#4c4c4c),color-stop(12%,#595959),color-stop(25%,#666),color-stop(39%,#474747),color-stop(50%,#2c2c2c),color-stop(51%,#000),color-stop(60%,#111),color-stop(76%,#2b2b2b),color-stop(91%,#1c1c1c),color-stop(100%,#131313));background:-webkit-linear-gradient(top,#4c4c4c,#595959 12%,#666 25%,#474747 39%,#2c2c2c 50%,#000 51%,#111 60%,#2b2b2b 76%,#1c1c1c 91%,#131313);background:-o-linear-gradient(top,#4c4c4c 0,#595959 12%,#666 25%,#474747 39%,#2c2c2c 50%,#000 51%,#111 60%,#2b2b2b 76%,#1c1c1c 91%,#131313 100%);background:-ms-linear-gradient(top,#4c4c4c 0,#595959 12%,#666 25%,#474747 39%,#2c2c2c 50%,#000 51%,#111 60%,#2b2b2b 76%,#1c1c1c 91%,#131313 100%);background:linear-gradient(180deg,#4c4c4c 0,#595959 12%,#666 25%,#474747 39%,#2c2c2c 50%,#000 51%,#111 60%,#2b2b2b 76%,#1c1c1c 91%,#131313);border:1px solid #fff;border-radius:15px;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#4c4c4c",endColorstr="#131313",GradientType=0);height:15px;left:137px;position:absolute;top:137px;width:15px}div.vis-color-picker div.vis-new-color{left:159px;padding-right:2px;text-align:right}div.vis-color-picker div.vis-initial-color,div.vis-color-picker div.vis-new-color{border:1px solid rgba(0,0,0,.1);border-radius:5px;color:rgba(0,0,0,.4);font-size:10px;height:20px;line-height:20px;position:absolute;top:380px;vertical-align:middle;width:140px}div.vis-color-picker div.vis-initial-color{left:10px;padding-left:2px;text-align:left}div.vis-color-picker div.vis-label{left:10px;position:absolute;width:300px}div.vis-color-picker div.vis-label.vis-brightness{top:300px}div.vis-color-picker div.vis-label.vis-opacity{top:338px}div.vis-color-picker div.vis-button{background-color:#f7f7f7;border:2px solid #d9d9d9;border-radius:10px;cursor:pointer;height:25px;line-height:25px;position:absolute;text-align:center;top:410px;vertical-align:middle;width:68px}div.vis-color-picker div.vis-button.vis-cancel{left:5px}div.vis-color-picker div.vis-button.vis-load{left:82px}div.vis-color-picker div.vis-button.vis-apply{left:159px}div.vis-color-picker div.vis-button.vis-save{left:236px}div.vis-color-picker input.vis-range{height:20px;width:290px}');Le('div.vis-configuration{display:block;float:left;font-size:12px;position:relative}div.vis-configuration-wrapper{display:block;width:700px}div.vis-configuration-wrapper:after{clear:both;content:"";display:block}div.vis-configuration.vis-config-option-container{background-color:#fff;border:2px solid #f7f8fa;border-radius:4px;display:block;left:10px;margin-top:20px;padding-left:5px;width:495px}div.vis-configuration.vis-config-button{background-color:#f7f8fa;border:2px solid #ceced0;border-radius:4px;cursor:pointer;display:block;height:25px;left:10px;line-height:25px;margin-bottom:30px;margin-top:20px;padding-left:5px;vertical-align:middle;width:495px}div.vis-configuration.vis-config-button.hover{background-color:#4588e6;border:2px solid #214373;color:#fff}div.vis-configuration.vis-config-item{display:block;float:left;height:25px;line-height:25px;vertical-align:middle;width:495px}div.vis-configuration.vis-config-item.vis-config-s2{background-color:#f7f8fa;border-radius:3px;left:10px;padding-left:5px}div.vis-configuration.vis-config-item.vis-config-s3{background-color:#e4e9f0;border-radius:3px;left:20px;padding-left:5px}div.vis-configuration.vis-config-item.vis-config-s4{background-color:#cfd8e6;border-radius:3px;left:30px;padding-left:5px}div.vis-configuration.vis-config-header{font-size:18px;font-weight:700}div.vis-configuration.vis-config-label{height:25px;line-height:25px;width:120px}div.vis-configuration.vis-config-label.vis-config-s3{width:110px}div.vis-configuration.vis-config-label.vis-config-s4{width:100px}div.vis-configuration.vis-config-colorBlock{border:1px solid #444;border-radius:2px;cursor:pointer;height:19px;margin:0;padding:0;top:1px;width:30px}input.vis-configuration.vis-config-checkbox{left:-5px}input.vis-configuration.vis-config-rangeinput{margin:0;padding:1px;pointer-events:none;position:relative;top:-5px;width:60px}input.vis-configuration.vis-config-range{-webkit-appearance:none;background-color:transparent;border:0 solid #fff;height:20px;width:300px}input.vis-configuration.vis-config-range::-webkit-slider-runnable-track{background:#dedede;background:-moz-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#dedede),color-stop(99%,#c8c8c8));background:-webkit-linear-gradient(top,#dedede,#c8c8c8 99%);background:-o-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:-ms-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:linear-gradient(180deg,#dedede 0,#c8c8c8 99%);border:1px solid #999;border-radius:3px;box-shadow:0 0 3px 0 #aaa;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#dedede",endColorstr="#c8c8c8",GradientType=0);height:5px;width:300px}input.vis-configuration.vis-config-range::-webkit-slider-thumb{-webkit-appearance:none;background:#3876c2;background:-moz-linear-gradient(top,#3876c2 0,#385380 100%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#3876c2),color-stop(100%,#385380));background:-webkit-linear-gradient(top,#3876c2,#385380);background:-o-linear-gradient(top,#3876c2 0,#385380 100%);background:-ms-linear-gradient(top,#3876c2 0,#385380 100%);background:linear-gradient(180deg,#3876c2 0,#385380);border:1px solid #14334b;border-radius:50%;box-shadow:0 0 1px 0 #111927;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#3876c2",endColorstr="#385380",GradientType=0);height:17px;margin-top:-7px;width:17px}input.vis-configuration.vis-config-range:focus{outline:none}input.vis-configuration.vis-config-range:focus::-webkit-slider-runnable-track{background:#9d9d9d;background:-moz-linear-gradient(top,#9d9d9d 0,#c8c8c8 99%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#9d9d9d),color-stop(99%,#c8c8c8));background:-webkit-linear-gradient(top,#9d9d9d,#c8c8c8 99%);background:-o-linear-gradient(top,#9d9d9d 0,#c8c8c8 99%);background:-ms-linear-gradient(top,#9d9d9d 0,#c8c8c8 99%);background:linear-gradient(180deg,#9d9d9d 0,#c8c8c8 99%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#9d9d9d",endColorstr="#c8c8c8",GradientType=0)}input.vis-configuration.vis-config-range::-moz-range-track{background:#dedede;background:-moz-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#dedede),color-stop(99%,#c8c8c8));background:-webkit-linear-gradient(top,#dedede,#c8c8c8 99%);background:-o-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:-ms-linear-gradient(top,#dedede 0,#c8c8c8 99%);background:linear-gradient(180deg,#dedede 0,#c8c8c8 99%);border:1px solid #999;border-radius:3px;box-shadow:0 0 3px 0 #aaa;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#dedede",endColorstr="#c8c8c8",GradientType=0);height:10px;width:300px}input.vis-configuration.vis-config-range::-moz-range-thumb{background:#385380;border:none;border-radius:50%;height:16px;width:16px}input.vis-configuration.vis-config-range:-moz-focusring{outline:1px solid #fff;outline-offset:-1px}input.vis-configuration.vis-config-range::-ms-track{background:transparent;border-color:transparent;border-width:6px 0;color:transparent;height:5px;width:300px}input.vis-configuration.vis-config-range::-ms-fill-lower{background:#777;border-radius:10px}input.vis-configuration.vis-config-range::-ms-fill-upper{background:#ddd;border-radius:10px}input.vis-configuration.vis-config-range::-ms-thumb{background:#385380;border:none;border-radius:50%;height:16px;width:16px}input.vis-configuration.vis-config-range:focus::-ms-fill-lower{background:#888}input.vis-configuration.vis-config-range:focus::-ms-fill-upper{background:#ccc}.vis-configuration-popup{background:rgba(57,76,89,.85);border:2px solid #f2faff;border-radius:4px;color:#fff;font-size:14px;height:30px;line-height:30px;position:absolute;text-align:center;-webkit-transition:opacity .3s ease-in-out;-moz-transition:opacity .3s ease-in-out;transition:opacity .3s ease-in-out;width:150px}.vis-configuration-popup:after,.vis-configuration-popup:before{border:solid transparent;content:" ";height:0;left:100%;pointer-events:none;position:absolute;top:50%;width:0}.vis-configuration-popup:after{border-color:rgba(136,183,213,0) rgba(136,183,213,0) rgba(136,183,213,0) rgba(57,76,89,.85);border-width:8px;margin-top:-8px}.vis-configuration-popup:before{border-color:rgba(194,225,245,0) rgba(194,225,245,0) rgba(194,225,245,0) #f2faff;border-width:12px;margin-top:-12px}');Le("div.vis-tooltip{background-color:#f5f4ed;border:1px solid #808074;-moz-border-radius:3px;-webkit-border-radius:3px;border-radius:3px;box-shadow:3px 3px 10px rgba(0,0,0,.2);color:#000;font-family:verdana;font-size:14px;padding:5px;pointer-events:none;position:absolute;visibility:hidden;white-space:nowrap;z-index:5}");var je={exports:{}};!function(g){function t(g){if(g)return function(g){for(var A in t.prototype)g[A]=t.prototype[A];return g}(g)}g.exports=t,t.prototype.on=t.prototype.addEventListener=function(g,t){return this._callbacks=this._callbacks||{},(this._callbacks["$"+g]=this._callbacks["$"+g]||[]).push(t),this},t.prototype.once=function(g,t){function A(){this.off(g,A),t.apply(this,arguments)}return A.fn=t,this.on(g,A),this},t.prototype.off=t.prototype.removeListener=t.prototype.removeAllListeners=t.prototype.removeEventListener=function(g,t){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var A,e=this._callbacks["$"+g];if(!e)return this;if(1==arguments.length)return delete this._callbacks["$"+g],this;for(var C=0;C<e.length;C++)if((A=e[C])===t||A.fn===t){e.splice(C,1);break}return 0===e.length&&delete this._callbacks["$"+g],this},t.prototype.emit=function(g){this._callbacks=this._callbacks||{};for(var t=new Array(arguments.length-1),A=this._callbacks["$"+g],e=1;e<arguments.length;e++)t[e-1]=arguments[e];if(A){e=0;for(var C=(A=A.slice(0)).length;e<C;++e)A[e].apply(this,t)}return this},t.prototype.listeners=function(g){return this._callbacks=this._callbacks||{},this._callbacks["$"+g]||[]},t.prototype.hasListeners=function(g){return!!this.listeners(g).length}}(je);var Ve=je.exports,Ye={};Ye[Ct("toStringTag")]="z";var We="[object z]"===String(Ye),Qe=e,_e=We,Ue=p,He=M,Ke=Ct("toStringTag"),Xe=Qe.Object,Je="Arguments"==He(function(){return arguments}()),qe=_e?He:function(g){var t,A,e;return void 0===g?"Undefined":null===g?"Null":"string"==typeof(A=function(g,t){try{return g[t]}catch(g){}}(t=Xe(g),Ke))?A:Je?He(t):"Object"==(e=He(t))&&Ue(t.callee)?"Arguments":e},$e=qe,gC=e.String,tC=function(g){if("Symbol"===$e(g))throw TypeError("Cannot convert a Symbol value to a string");return gC(g)},AC=u,eC=xA,CC=tC,IC=j,iC=AC("".charAt),oC=AC("".charCodeAt),nC=AC("".slice),rC=function(g){return function(t,A){var e,C,I=CC(IC(t)),i=eC(A),o=I.length;return i<0||i>=o?g?"":void 0:(e=oC(I,i))<55296||e>56319||i+1===o||(C=oC(I,i+1))<56320||C>57343?g?iC(I,i):e:g?nC(I,i,i+2):C-56320+(e-55296<<10)+65536}},sC={codeAt:rC(!1),charAt:rC(!0)},aC=p,dC=zg,hC=u(Function.toString);aC(dC.inspectSource)||(dC.inspectSource=function(g){return hC(g)});var lC,cC,uC,pC=dC.inspectSource,fC=p,vC=pC,yC=e.WeakMap,mC=fC(yC)&&/native code/.test(vC(yC)),bC=Ng.exports,wC=Ug,kC=bC("keys"),xC=function(g){return kC[g]||(kC[g]=wC(g))},EC=mC,OC=e,TC=u,DC=_,NC=sA,RC=Vg,PC=zg,MC=xC,BC=GA,zC="Object already initialized",ZC=OC.TypeError,SC=OC.WeakMap;if(EC||PC.state){var FC=PC.state||(PC.state=new SC),GC=TC(FC.get),LC=TC(FC.has),jC=TC(FC.set);lC=function(g,t){if(LC(FC,g))throw new ZC(zC);return t.facade=g,jC(FC,g,t),t},cC=function(g){return GC(FC,g)||{}},uC=function(g){return LC(FC,g)}}else{var VC=MC("state");BC[VC]=!0,lC=function(g,t){if(RC(g,VC))throw new ZC(zC);return t.facade=g,NC(g,VC,t),t},cC=function(g){return RC(g,VC)?g[VC]:{}},uC=function(g){return RC(g,VC)}}var YC={set:lC,get:cC,has:uC,enforce:function(g){return uC(g)?cC(g):lC(g,{})},getterFor:function(g){return function(t){var A;if(!DC(t)||(A=cC(t)).type!==g)throw ZC("Incompatible receiver, "+g+" required");return A}}},WC=v,QC=Vg,_C=Function.prototype,UC=WC&&Object.getOwnPropertyDescriptor,HC=QC(_C,"name"),KC={EXISTS:HC,PROPER:HC&&"something"===function(){}.name,CONFIGURABLE:HC&&(!WC||WC&&UC(_C,"name").configurable)},XC={},JC=v,qC=Qt,$C=Wt,gI=Xt,tI=W,AI=KA;XC.f=JC&&!qC?Object.defineProperties:function(g,t){gI(g);for(var A,e=tI(t),C=AI(t),I=C.length,i=0;I>i;)$C.f(g,A=C[i++],e[A]);return g};var eI,CI=q("document","documentElement"),II=Xt,iI=XC,oI=_A,nI=GA,rI=CI,sI=vt,aI=xC("IE_PROTO"),dI=function(){},hI=function(g){return"<script>"+g+"</"+"script>"},lI=function(g){g.write(hI("")),g.close();var t=g.parentWindow.Object;return g=null,t},cI=function(){try{eI=new ActiveXObject("htmlfile")}catch(g){}var g,t;cI="undefined"!=typeof document?document.domain&&eI?lI(eI):((t=sI("iframe")).style.display="none",rI.appendChild(t),t.src=String("javascript:"),(g=t.contentWindow.document).open(),g.write(hI("document.F=Object")),g.close(),g.F):lI(eI);for(var A=oI.length;A--;)delete cI.prototype[oI[A]];return cI()};nI[aI]=!0;var uI,pI,fI,vI=Object.create||function(g,t){var A;return null!==g?(dI.prototype=II(g),A=new dI,dI.prototype=null,A[aI]=g):A=cI(),void 0===t?A:iI.f(A,t)},yI=!C((function(){function g(){}return g.prototype.constructor=null,Object.getPrototypeOf(new g)!==g.prototype})),mI=e,bI=Vg,wI=p,kI=Gg,xI=yI,EI=xC("IE_PROTO"),OI=mI.Object,TI=OI.prototype,DI=xI?OI.getPrototypeOf:function(g){var t=kI(g);if(bI(t,EI))return t[EI];var A=t.constructor;return wI(A)&&t instanceof A?A.prototype:t instanceof OI?TI:null},NI=sA,RI=function(g,t,A,e){e&&e.enumerable?g[t]=A:NI(g,t,A)},PI=C,MI=p,BI=vI,zI=DI,ZI=RI,SI=Ct("iterator"),FI=!1;[].keys&&("next"in(fI=[].keys())?(pI=zI(zI(fI)))!==Object.prototype&&(uI=pI):FI=!0);var GI=null==uI||PI((function(){var g={};return uI[SI].call(g)!==g}));MI((uI=GI?{}:BI(uI))[SI])||ZI(uI,SI,(function(){return this}));var LI={IteratorPrototype:uI,BUGGY_SAFARI_ITERATORS:FI},jI=qe,VI=We?{}.toString:function(){return"[object "+jI(this)+"]"},YI=We,WI=Wt.f,QI=sA,_I=Vg,UI=VI,HI=Ct("toStringTag"),KI=function(g,t,A,e){if(g){var C=A?g:g.prototype;_I(C,HI)||WI(C,HI,{configurable:!0,value:t}),e&&!YI&&QI(C,"toString",UI)}},XI={},JI=LI.IteratorPrototype,qI=vI,$I=D,gi=KI,ti=XI,Ai=function(){return this},ei=e,Ci=p,Ii=ei.String,ii=ei.TypeError,oi=u,ni=Xt,ri=function(g){if("object"==typeof g||Ci(g))return g;throw ii("Can't set "+Ii(g)+" as a prototype")},si=Object.setPrototypeOf||("__proto__"in{}?function(){var g,t=!1,A={};try{(g=oi(Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set))(A,[]),t=A instanceof Array}catch(g){}return function(A,e){return ni(A),ri(e),t?g(A,e):A.__proto__=e,A}}():void 0),ai=bA,di=b,hi=function(g,t,A,e){var C=t+" Iterator";return g.prototype=qI(JI,{next:$I(+!e,A)}),gi(g,C,!1,!0),ti[C]=Ai,g},li=DI,ci=KI,ui=RI,pi=XI,fi=KC.PROPER,vi=LI.BUGGY_SAFARI_ITERATORS,yi=Ct("iterator"),mi="keys",bi="values",wi="entries",ki=function(){return this},xi=function(g,t,A,e,C,I,i){hi(A,t,e);var o,n,r,s=function(g){if(g===C&&c)return c;if(!vi&&g in h)return h[g];switch(g){case mi:case bi:case wi:return function(){return new A(this,g)}}return function(){return new A(this)}},a=t+" Iterator",d=!1,h=g.prototype,l=h[yi]||h["@@iterator"]||C&&h[C],c=!vi&&l||s(C),u="Array"==t&&h.entries||l;if(u&&(o=li(u.call(new g)))!==Object.prototype&&o.next&&(ci(o,a,!0,!0),pi[a]=ki),fi&&C==bi&&l&&l.name!==bi&&(d=!0,c=function(){return di(l,this)}),C)if(n={values:s(bi),keys:I?c:s(mi),entries:s(wi)},i)for(r in n)(vi||d||!(r in h))&&ui(h,r,n[r]);else ai({target:t,proto:!0,forced:vi||d},n);return i&&h[yi]!==c&&ui(h,yi,c,{name:C}),pi[t]=c,n},Ei=sC.charAt,Oi=tC,Ti=YC,Di=xi,Ni="String Iterator",Ri=Ti.set,Pi=Ti.getterFor(Ni);Di(String,"String",(function(g){Ri(this,{type:Ni,string:Oi(g),index:0})}),(function(){var g,t=Pi(this),A=t.string,e=t.index;return e>=A.length?{value:void 0,done:!0}:(g=Ei(A,e),t.index+=g.length,{value:g,done:!1})}));var Mi=b,Bi=Xt,zi=xg,Zi=function(g,t,A){var e,C;Bi(g);try{if(!(e=zi(g,"return"))){if("throw"===t)throw A;return A}e=Mi(e,g)}catch(g){C=!0,e=g}if("throw"===t)throw A;if(C)throw e;return Bi(e),A},Si=Xt,Fi=Zi,Gi=XI,Li=Ct("iterator"),ji=Array.prototype,Vi=function(g){return void 0!==g&&(Gi.Array===g||ji[Li]===g)},Yi=u,Wi=C,Qi=p,_i=qe,Ui=pC,Hi=function(){},Ki=[],Xi=q("Reflect","construct"),Ji=/^\s*(?:class|function)\b/,qi=Yi(Ji.exec),$i=!Ji.exec(Hi),go=function(g){if(!Qi(g))return!1;try{return Xi(Hi,Ki,g),!0}catch(g){return!1}},to=function(g){if(!Qi(g))return!1;switch(_i(g)){case"AsyncFunction":case"GeneratorFunction":case"AsyncGeneratorFunction":return!1}try{return $i||!!qi(Ji,Ui(g))}catch(g){return!0}};to.sham=!0;var Ao=!Xi||Wi((function(){var g;return go(go.call)||!go(Object)||!go((function(){g=!0}))||g}))?to:go,eo=ct,Co=Wt,Io=D,io=function(g,t,A){var e=eo(t);e in g?Co.f(g,e,Io(0,A)):g[e]=A},oo=qe,no=xg,ro=XI,so=Ct("iterator"),ao=function(g){if(null!=g)return no(g,so)||no(g,"@@iterator")||ro[oo(g)]},ho=b,lo=wg,co=Xt,uo=vg,po=ao,fo=e.TypeError,vo=function(g,t){var A=arguments.length<2?po(g):t;if(lo(A))return co(ho(A,g));throw fo(uo(g)+" is not iterable")},yo=Yt,mo=b,bo=Gg,wo=function(g,t,A,e){try{return e?t(Si(A)[0],A[1]):t(A)}catch(t){Fi(g,"throw",t)}},ko=Vi,xo=Ao,Eo=MA,Oo=io,To=vo,Do=ao,No=e.Array,Ro=Ct("iterator"),Po=!1;try{var Mo=0,Bo={next:function(){return{done:!!Mo++}},return:function(){Po=!0}};Bo[Ro]=function(){return this},Array.from(Bo,(function(){throw 2}))}catch(g){}var zo=function(g){var t=bo(g),A=xo(this),e=arguments.length,C=e>1?arguments[1]:void 0,I=void 0!==C;I&&(C=yo(C,e>2?arguments[2]:void 0));var i,o,n,r,s,a,d=Do(t),h=0;if(!d||this==No&&ko(d))for(i=Eo(t),o=A?new this(i):No(i);i>h;h++)a=I?C(t[h],h):t[h],Oo(o,h,a);else for(s=(r=To(t,d)).next,o=A?new this:[];!(n=mo(s,r)).done;h++)a=I?wo(r,C,[n.value,h],!0):n.value,Oo(o,h,a);return o.length=h,o},Zo=function(g,t){if(!t&&!Po)return!1;var A=!1;try{var e={};e[Ro]=function(){return{next:function(){return{done:A=!0}}}},g(e)}catch(g){}return A};bA({target:"Array",stat:!0,forced:!Zo((function(g){Array.from(g)}))},{from:zo});var So=U.Array.from,Fo=So,Go=W,Lo=XI,jo=YC;Wt.f;var Vo=xi,Yo="Array Iterator",Wo=jo.set,Qo=jo.getterFor(Yo);Vo(Array,"Array",(function(g,t){Wo(this,{type:Yo,target:Go(g),index:0,kind:t})}),(function(){var g=Qo(this),t=g.target,A=g.kind,e=g.index++;return!t||e>=t.length?(g.target=void 0,{value:void 0,done:!0}):"keys"==A?{value:e,done:!1}:"values"==A?{value:t[e],done:!1}:{value:[e,t[e]],done:!1}}),"values"),Lo.Arguments=Lo.Array;var _o=ao,Uo={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0},Ho=e,Ko=qe,Xo=sA,Jo=XI,qo=Ct("toStringTag");for(var $o in Uo){var gn=Ho[$o],tn=gn&&gn.prototype;tn&&Ko(tn)!==qo&&Xo(tn,qo,$o),Jo[$o]=Jo.Array}var An=_o,en=M,Cn=Array.isArray||function(g){return"Array"==en(g)},In={},on=QA,nn=_A.concat("length","prototype");In.f=Object.getOwnPropertyNames||function(g){return on(g,nn)};var rn={},sn=DA,an=MA,dn=io,hn=e.Array,ln=Math.max,cn=function(g,t,A){for(var e=an(g),C=sn(t,e),I=sn(void 0===A?e:A,e),i=hn(ln(I-C,0)),o=0;C<I;C++,o++)dn(i,o,g[C]);return i.length=o,i},un=M,pn=W,fn=In.f,vn=cn,yn="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[];rn.f=function(g){return yn&&"Window"==un(g)?function(g){try{return fn(g)}catch(g){return vn(yn)}}(g):fn(pn(g))};var mn={},bn=Ct;mn.f=bn;var wn=U,kn=Vg,xn=mn,En=Wt.f,On=function(g){var t=wn.Symbol||(wn.Symbol={});kn(t,g)||En(t,g,{value:xn.f(g)})},Tn=e,Dn=Cn,Nn=Ao,Rn=_,Pn=Ct("species"),Mn=Tn.Array,Bn=function(g){var t;return Dn(g)&&(t=g.constructor,(Nn(t)&&(t===Mn||Dn(t.prototype))||Rn(t)&&null===(t=t[Pn]))&&(t=void 0)),void 0===t?Mn:t},zn=function(g,t){return new(Bn(g))(0===t?0:t)},Zn=Yt,Sn=G,Fn=Gg,Gn=MA,Ln=zn,jn=u([].push),Vn=function(g){var t=1==g,A=2==g,e=3==g,C=4==g,I=6==g,i=7==g,o=5==g||I;return function(n,r,s,a){for(var d,h,l=Fn(n),c=Sn(l),u=Zn(r,s),p=Gn(c),f=0,v=a||Ln,y=t?v(n,p):A||i?v(n,0):void 0;p>f;f++)if((o||f in c)&&(h=u(d=c[f],f,l),g))if(t)y[f]=h;else if(h)switch(g){case 3:return!0;case 5:return d;case 6:return f;case 2:jn(y,d)}else switch(g){case 4:return!1;case 7:jn(y,d)}return I?-1:e||C?C:y}},Yn={forEach:Vn(0),map:Vn(1),filter:Vn(2),some:Vn(3),every:Vn(4),find:Vn(5),findIndex:Vn(6),filterReject:Vn(7)},Wn=bA,Qn=e,_n=q,Un=s,Hn=b,Kn=u,Xn=v,Jn=sg,qn=C,$n=Vg,gr=Cn,tr=p,Ar=_,er=$,Cr=pg,Ir=Xt,ir=Gg,or=W,nr=ct,rr=tC,sr=D,ar=vI,dr=KA,hr=In,lr=rn,cr=XA,ur=f,pr=Wt,fr=XC,vr=w,yr=de,mr=RI,br=Ng.exports,wr=GA,kr=Ug,xr=Ct,Er=mn,Or=On,Tr=KI,Dr=YC,Nr=Yn.forEach,Rr=xC("hidden"),Pr="Symbol",Mr=xr("toPrimitive"),Br=Dr.set,zr=Dr.getterFor(Pr),Zr=Object.prototype,Sr=Qn.Symbol,Fr=Sr&&Sr.prototype,Gr=Qn.TypeError,Lr=Qn.QObject,jr=_n("JSON","stringify"),Vr=ur.f,Yr=pr.f,Wr=lr.f,Qr=vr.f,_r=Kn([].push),Ur=br("symbols"),Hr=br("op-symbols"),Kr=br("string-to-symbol-registry"),Xr=br("symbol-to-string-registry"),Jr=br("wks"),qr=!Lr||!Lr.prototype||!Lr.prototype.findChild,$r=Xn&&qn((function(){return 7!=ar(Yr({},"a",{get:function(){return Yr(this,"a",{value:7}).a}})).a}))?function(g,t,A){var e=Vr(Zr,t);e&&delete Zr[t],Yr(g,t,A),e&&g!==Zr&&Yr(Zr,t,e)}:Yr,gs=function(g,t){var A=Ur[g]=ar(Fr);return Br(A,{type:Pr,tag:g,description:t}),Xn||(A.description=t),A},ts=function(g,t,A){g===Zr&&ts(Hr,t,A),Ir(g);var e=nr(t);return Ir(A),$n(Ur,e)?(A.enumerable?($n(g,Rr)&&g[Rr][e]&&(g[Rr][e]=!1),A=ar(A,{enumerable:sr(0,!1)})):($n(g,Rr)||Yr(g,Rr,sr(1,{})),g[Rr][e]=!0),$r(g,e,A)):Yr(g,e,A)},As=function(g,t){Ir(g);var A=or(t),e=dr(A).concat(is(A));return Nr(e,(function(t){Xn&&!Hn(es,A,t)||ts(g,t,A[t])})),g},es=function(g){var t=nr(g),A=Hn(Qr,this,t);return!(this===Zr&&$n(Ur,t)&&!$n(Hr,t))&&(!(A||!$n(this,t)||!$n(Ur,t)||$n(this,Rr)&&this[Rr][t])||A)},Cs=function(g,t){var A=or(g),e=nr(t);if(A!==Zr||!$n(Ur,e)||$n(Hr,e)){var C=Vr(A,e);return!C||!$n(Ur,e)||$n(A,Rr)&&A[Rr][e]||(C.enumerable=!0),C}},Is=function(g){var t=Wr(or(g)),A=[];return Nr(t,(function(g){$n(Ur,g)||$n(wr,g)||_r(A,g)})),A},is=function(g){var t=g===Zr,A=Wr(t?Hr:or(g)),e=[];return Nr(A,(function(g){!$n(Ur,g)||t&&!$n(Zr,g)||_r(e,Ur[g])})),e};if(Jn||(Sr=function(){if(er(Fr,this))throw Gr("Symbol is not a constructor");var g=arguments.length&&void 0!==arguments[0]?rr(arguments[0]):void 0,t=kr(g),A=function(g){this===Zr&&Hn(A,Hr,g),$n(this,Rr)&&$n(this[Rr],t)&&(this[Rr][t]=!1),$r(this,t,sr(1,g))};return Xn&&qr&&$r(Zr,t,{configurable:!0,set:A}),gs(t,g)},mr(Fr=Sr.prototype,"toString",(function(){return zr(this).tag})),mr(Sr,"withoutSetter",(function(g){return gs(kr(g),g)})),vr.f=es,pr.f=ts,fr.f=As,ur.f=Cs,hr.f=lr.f=Is,cr.f=is,Er.f=function(g){return gs(xr(g),g)},Xn&&Yr(Fr,"description",{configurable:!0,get:function(){return zr(this).description}})),Wn({global:!0,wrap:!0,forced:!Jn,sham:!Jn},{Symbol:Sr}),Nr(dr(Jr),(function(g){Or(g)})),Wn({target:Pr,stat:!0,forced:!Jn},{for:function(g){var t=rr(g);if($n(Kr,t))return Kr[t];var A=Sr(t);return Kr[t]=A,Xr[A]=t,A},keyFor:function(g){if(!Cr(g))throw Gr(g+" is not a symbol");if($n(Xr,g))return Xr[g]},useSetter:function(){qr=!0},useSimple:function(){qr=!1}}),Wn({target:"Object",stat:!0,forced:!Jn,sham:!Xn},{create:function(g,t){return void 0===t?ar(g):As(ar(g),t)},defineProperty:ts,defineProperties:As,getOwnPropertyDescriptor:Cs}),Wn({target:"Object",stat:!0,forced:!Jn},{getOwnPropertyNames:Is,getOwnPropertySymbols:is}),Wn({target:"Object",stat:!0,forced:qn((function(){cr.f(1)}))},{getOwnPropertySymbols:function(g){return cr.f(ir(g))}}),jr){var os=!Jn||qn((function(){var g=Sr();return"[null]"!=jr([g])||"{}"!=jr({a:g})||"{}"!=jr(Object(g))}));Wn({target:"JSON",stat:!0,forced:os},{stringify:function(g,t,A){var e=yr(arguments),C=t;if((Ar(t)||void 0!==g)&&!Cr(g))return gr(t)||(t=function(g,t){if(tr(C)&&(t=Hn(C,this,g,t)),!Cr(t))return t}),e[1]=t,Un(jr,null,e)}})}if(!Fr[Mr]){var ns=Fr.valueOf;mr(Fr,Mr,(function(g){return Hn(ns,this)}))}Tr(Sr,Pr),wr[Rr]=!0;var rs=U.Object.getOwnPropertySymbols,ss={exports:{}},as=bA,ds=C,hs=W,ls=f.f,cs=v,us=ds((function(){ls(1)}));as({target:"Object",stat:!0,forced:!cs||us,sham:!cs},{getOwnPropertyDescriptor:function(g,t){return ls(hs(g),t)}});var ps=U.Object,fs=ss.exports=function(g,t){return ps.getOwnPropertyDescriptor(g,t)};ps.getOwnPropertyDescriptor.sham&&(fs.sham=!0);var vs=ss.exports,ys=vs,ms=q,bs=In,ws=XA,ks=Xt,xs=u([].concat),Es=ms("Reflect","ownKeys")||function(g){var t=bs.f(ks(g)),A=ws.f;return A?xs(t,A(g)):t},Os=Es,Ts=W,Ds=f,Ns=io;bA({target:"Object",stat:!0,sham:!v},{getOwnPropertyDescriptors:function(g){for(var t,A,e=Ts(g),C=Ds.f,I=Os(e),i={},o=0;I.length>o;)void 0!==(A=C(e,t=I[o++]))&&Ns(i,t,A);return i}});var Rs=U.Object.getOwnPropertyDescriptors,Ps={exports:{}},Ms=bA,Bs=v,zs=XC.f;Ms({target:"Object",stat:!0,forced:Object.defineProperties!==zs,sham:!Bs},{defineProperties:zs});var Zs=U.Object,Ss=Ps.exports=function(g,t){return Zs.defineProperties(g,t)};Zs.defineProperties.sham&&(Ss.sham=!0);var Fs=Ps.exports,Gs={exports:{}},Ls=bA,js=v,Vs=Wt.f;Ls({target:"Object",stat:!0,forced:Object.defineProperty!==Vs,sham:!js},{defineProperty:Vs});var Ys=U.Object,Ws=Gs.exports=function(g,t,A){return Ys.defineProperty(g,t,A)};Ys.defineProperty.sham&&(Ws.sham=!0);var Qs=Gs.exports,_s=Qs;function Us(g,t){if(!(g instanceof t))throw new TypeError("Cannot call a class as a function")}var Hs=Qs;function Ks(g,t){for(var A=0;A<t.length;A++){var e=t[A];e.enumerable=e.enumerable||!1,e.configurable=!0,"value"in e&&(e.writable=!0),Hs(g,e.key,e)}}function Xs(g,t,A){return t&&Ks(g.prototype,t),A&&Ks(g,A),Hs(g,"prototype",{writable:!1}),g}function Js(g,t,A){return t in g?Hs(g,t,{value:A,enumerable:!0,configurable:!0,writable:!0}):g[t]=A,g}bA({target:"Array",stat:!0},{isArray:Cn});var qs=U.Array.isArray,$s=qs;var ga=C,ta=og,Aa=Ct("species"),ea=function(g){return ta>=51||!ga((function(){var t=[];return(t.constructor={})[Aa]=function(){return{foo:1}},1!==t[g](Boolean).foo}))},Ca=bA,Ia=e,ia=C,oa=Cn,na=_,ra=Gg,sa=MA,aa=io,da=zn,ha=ea,la=og,ca=Ct("isConcatSpreadable"),ua=9007199254740991,pa="Maximum allowed index exceeded",fa=Ia.TypeError,va=la>=51||!ia((function(){var g=[];return g[ca]=!1,g.concat()[0]!==g})),ya=ha("concat"),ma=function(g){if(!na(g))return!1;var t=g[ca];return void 0!==t?!!t:oa(g)};Ca({target:"Array",proto:!0,forced:!va||!ya},{concat:function(g){var t,A,e,C,I,i=ra(this),o=da(i,0),n=0;for(t=-1,e=arguments.length;t<e;t++)if(ma(I=-1===t?i:arguments[t])){if(n+(C=sa(I))>ua)throw fa(pa);for(A=0;A<C;A++,n++)A in I&&aa(o,n,I[A])}else{if(n>=ua)throw fa(pa);aa(o,n++,I)}return o.length=n,o}}),On("asyncIterator"),On("hasInstance"),On("isConcatSpreadable"),On("iterator"),On("match"),On("matchAll"),On("replace"),On("search"),On("species"),On("split"),On("toPrimitive"),On("toStringTag"),On("unscopables"),KI(e.JSON,"JSON",!0);var ba=U.Symbol,wa=ba;On("asyncDispose"),On("dispose"),On("matcher"),On("metadata"),On("observable"),On("patternMatch"),On("replaceAll");var ka=wa;var xa=bA,Ea=e,Oa=Cn,Ta=Ao,Da=_,Na=DA,Ra=MA,Pa=W,Ma=io,Ba=Ct,za=de,Za=ea("slice"),Sa=Ba("species"),Fa=Ea.Array,Ga=Math.max;xa({target:"Array",proto:!0,forced:!Za},{slice:function(g,t){var A,e,C,I=Pa(this),i=Ra(I),o=Na(g,i),n=Na(void 0===t?i:t,i);if(Oa(I)&&(A=I.constructor,(Ta(A)&&(A===Fa||Oa(A.prototype))||Da(A)&&null===(A=A[Sa]))&&(A=void 0),A===Fa||void 0===A))return za(I,o,n);for(e=new(void 0===A?Fa:A)(Ga(n-o,0)),C=0;o<n;o++,C++)o in I&&Ma(e,C,I[o]);return e.length=C,e}});var La=Oe("Array").slice,ja=$,Va=La,Ya=Array.prototype,Wa=function(g){var t=g.slice;return g===Ya||ja(Ya,g)&&t===Ya.slice?Va:t},Qa=Wa,_a=Qa,Ua=So;function Ha(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}function Ka(g,t){var A;if(g){if("string"==typeof g)return Ha(g,t);var e=_a(A=Object.prototype.toString.call(g)).call(A,8,-1);return"Object"===e&&g.constructor&&(e=g.constructor.name),"Map"===e||"Set"===e?Ua(g):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?Ha(g,t):void 0}}function Xa(g,t){return function(g){if($s(g))return g}(g)||function(g,t){var A=null==g?null:void 0!==ka&&An(g)||g["@@iterator"];if(null!=A){var e,C,I=[],i=!0,o=!1;try{for(A=A.call(g);!(i=(e=A.next()).done)&&(I.push(e.value),!t||I.length!==t);i=!0);}catch(g){o=!0,C=g}finally{try{i||null==A.return||A.return()}finally{if(o)throw C}}return I}}(g,t)||Ka(g,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}var Ja=mn.f("iterator"),qa=Ja;function $a(g){return $a="function"==typeof ka&&"symbol"==typeof qa?function(g){return typeof g}:function(g){return g&&"function"==typeof ka&&g.constructor===ka&&g!==ka.prototype?"symbol":typeof g},$a(g)}function gd(g){return function(g){if($s(g))return Ha(g)}(g)||function(g){if(void 0!==ka&&null!=An(g)||null!=g["@@iterator"])return Ua(g)}(g)||Ka(g)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}var td=ba,Ad=Oe("Array").concat,ed=$,Cd=Ad,Id=Array.prototype,id=function(g){var t=g.concat;return g===Id||ed(Id,g)&&t===Id.concat?Cd:t},od=id,nd=Qa;bA({target:"Reflect",stat:!0},{ownKeys:Es});var rd=U.Reflect.ownKeys,sd=qs,ad=Yn.map;bA({target:"Array",proto:!0,forced:!ea("map")},{map:function(g){return ad(this,g,arguments.length>1?arguments[1]:void 0)}});var dd=Oe("Array").map,hd=$,ld=dd,cd=Array.prototype,ud=function(g){var t=g.map;return g===cd||hd(cd,g)&&t===cd.map?ld:t},pd=ud,fd=Gg,vd=KA;bA({target:"Object",stat:!0,forced:C((function(){vd(1)}))},{keys:function(g){return vd(fd(g))}});var yd=U.Object.keys,md=bA,bd=u,wd=e.Date,kd=bd(wd.prototype.getTime);md({target:"Date",stat:!0},{now:function(){return kd(new wd)}});var xd=U.Date.now,Ed=C,Od=function(g,t){var A=[][g];return!!A&&Ed((function(){A.call(null,t||function(){return 1},1)}))},Td=Yn.forEach,Dd=Od("forEach")?[].forEach:function(g){return Td(this,g,arguments.length>1?arguments[1]:void 0)};bA({target:"Array",proto:!0,forced:[].forEach!=Dd},{forEach:Dd});var Nd=Oe("Array").forEach,Rd=qe,Pd=Vg,Md=$,Bd=Nd,zd=Array.prototype,Zd={DOMTokenList:!0,NodeList:!0},Sd=function(g){var t=g.forEach;return g===zd||Md(zd,g)&&t===zd.forEach||Pd(Zd,Rd(g))?Bd:t},Fd=bA,Gd=Cn,Ld=u([].reverse),jd=[1,2];Fd({target:"Array",proto:!0,forced:String(jd)===String(jd.reverse())},{reverse:function(){return Gd(this)&&(this.length=this.length),Ld(this)}});var Vd=Oe("Array").reverse,Yd=$,Wd=Vd,Qd=Array.prototype,_d=function(g){var t=g.reverse;return g===Qd||Yd(Qd,g)&&t===Qd.reverse?Wd:t},Ud=_d,Hd=bA,Kd=e,Xd=DA,Jd=xA,qd=MA,$d=Gg,gh=zn,th=io,Ah=ea("splice"),eh=Kd.TypeError,Ch=Math.max,Ih=Math.min,ih=9007199254740991,oh="Maximum allowed length exceeded";Hd({target:"Array",proto:!0,forced:!Ah},{splice:function(g,t){var A,e,C,I,i,o,n=$d(this),r=qd(n),s=Xd(g,r),a=arguments.length;if(0===a?A=e=0:1===a?(A=0,e=r-s):(A=a-2,e=Ih(Ch(Jd(t),0),r-s)),r+A-e>ih)throw eh(oh);for(C=gh(n,e),I=0;I<e;I++)(i=s+I)in n&&th(C,I,n[i]);if(C.length=e,A<e){for(I=s;I<r-e;I++)o=I+A,(i=I+e)in n?n[o]=n[i]:delete n[o];for(I=r;I>r-e+A;I--)delete n[I-1]}else if(A>e)for(I=r-e;I>s;I--)o=I+A-1,(i=I+e-1)in n?n[o]=n[i]:delete n[o];for(I=0;I<A;I++)n[I+s]=arguments[I+2];return n.length=r-e+A,C}});var nh=Oe("Array").splice,rh=$,sh=nh,ah=Array.prototype,dh=function(g){var t=g.splice;return g===ah||rh(ah,g)&&t===ah.splice?sh:t},hh=dh,lh=FA.includes;bA({target:"Array",proto:!0},{includes:function(g){return lh(this,g,arguments.length>1?arguments[1]:void 0)}});var ch=Oe("Array").includes,uh=_,ph=M,fh=Ct("match"),vh=function(g){var t;return uh(g)&&(void 0!==(t=g[fh])?!!t:"RegExp"==ph(g))},yh=e.TypeError,mh=Ct("match"),bh=bA,wh=function(g){if(vh(g))throw yh("The method doesn't accept regular expressions");return g},kh=j,xh=tC,Eh=function(g){var t=/./;try{"/./"[g](t)}catch(A){try{return t[mh]=!1,"/./"[g](t)}catch(g){}}return!1},Oh=u("".indexOf);bh({target:"String",proto:!0,forced:!Eh("includes")},{includes:function(g){return!!~Oh(xh(kh(this)),xh(wh(g)),arguments.length>1?arguments[1]:void 0)}});var Th=Oe("String").includes,Dh=$,Nh=ch,Rh=Th,Ph=Array.prototype,Mh=String.prototype,Bh=function(g){var t=g.includes;return g===Ph||Dh(Ph,g)&&t===Ph.includes?Nh:"string"==typeof g||g===Mh||Dh(Mh,g)&&t===Mh.includes?Rh:t},zh=Bh,Zh=Gg,Sh=DI,Fh=yI;bA({target:"Object",stat:!0,forced:C((function(){Sh(1)})),sham:!Fh},{getPrototypeOf:function(g){return Sh(Zh(g))}});var Gh=U.Object.getPrototypeOf,Lh=Gh,jh=Yn.filter;bA({target:"Array",proto:!0,forced:!ea("filter")},{filter:function(g){return jh(this,g,arguments.length>1?arguments[1]:void 0)}});var Vh=Oe("Array").filter,Yh=$,Wh=Vh,Qh=Array.prototype,_h=function(g){var t=g.filter;return g===Qh||Yh(Qh,g)&&t===Qh.filter?Wh:t},Uh=_h,Hh=v,Kh=u,Xh=KA,Jh=W,qh=Kh(w.f),$h=Kh([].push),gl=function(g){return function(t){for(var A,e=Jh(t),C=Xh(e),I=C.length,i=0,o=[];I>i;)A=C[i++],Hh&&!qh(e,A)||$h(o,g?[A,e[A]]:e[A]);return o}},tl={entries:gl(!0),values:gl(!1)}.values;bA({target:"Object",stat:!0},{values:function(g){return tl(g)}}),U.Object.values;var Al="\t\n\v\f\r                　\u2028\u2029\ufeff",el=j,Cl=tC,Il=u("".replace),il="[\t\n\v\f\r                　\u2028\u2029\ufeff]",ol=RegExp("^"+il+il+"*"),nl=RegExp(il+il+"*$"),rl=function(g){return function(t){var A=Cl(el(t));return 1&g&&(A=Il(A,ol,"")),2&g&&(A=Il(A,nl,"")),A}},sl={start:rl(1),end:rl(2),trim:rl(3)},al=e,dl=C,hl=u,ll=tC,cl=sl.trim,ul=Al,pl=al.parseInt,fl=al.Symbol,vl=fl&&fl.iterator,yl=/^[+-]?0x/i,ml=hl(yl.exec),bl=8!==pl(ul+"08")||22!==pl(ul+"0x16")||vl&&!dl((function(){pl(Object(vl))}))?function(g,t){var A=cl(ll(g));return pl(A,t>>>0||(ml(yl,A)?16:10))}:pl;bA({global:!0,forced:parseInt!=bl},{parseInt:bl});var wl=U.parseInt,kl=bA,xl=FA.indexOf,El=Od,Ol=u([].indexOf),Tl=!!Ol&&1/Ol([1],1,-0)<0,Dl=El("indexOf");kl({target:"Array",proto:!0,forced:Tl||!Dl},{indexOf:function(g){var t=arguments.length>1?arguments[1]:void 0;return Tl?Ol(this,g,t)||0:xl(this,g,t)}});var Nl=Oe("Array").indexOf,Rl=$,Pl=Nl,Ml=Array.prototype,Bl=function(g){var t=g.indexOf;return g===Ml||Rl(Ml,g)&&t===Ml.indexOf?Pl:t},zl=Bl,Zl=KC.PROPER,Sl=C,Fl=Al,Gl=sl.trim;bA({target:"String",proto:!0,forced:function(g){return Sl((function(){return!!Fl[g]()||"​
+᠎"!=="​
+᠎"[g]()||Zl&&Fl[g].name!==g}))}("trim")},{trim:function(){return Gl(this)}}),Oe("String").trim,bA({target:"Object",stat:!0,sham:!v},{create:vI});var Ll=U.Object,jl=function(g,t){return Ll.create(g,t)},Vl=jl,Yl=bA,Wl=q,Ql=s,_l=u,Ul=C,Hl=e.Array,Kl=Wl("JSON","stringify"),Xl=_l(/./.exec),Jl=_l("".charAt),ql=_l("".charCodeAt),$l=_l("".replace),gc=_l(1..toString),tc=/[\uD800-\uDFFF]/g,Ac=/^[\uD800-\uDBFF]$/,ec=/^[\uDC00-\uDFFF]$/,Cc=function(g,t,A){var e=Jl(A,t-1),C=Jl(A,t+1);return Xl(Ac,g)&&!Xl(ec,C)||Xl(ec,g)&&!Xl(Ac,e)?"\\u"+gc(ql(g,0),16):g},Ic=Ul((function(){return'"\\udf06\\ud834"'!==Kl("\udf06\ud834")||'"\\udead"'!==Kl("\udead")}));Kl&&Yl({target:"JSON",stat:!0,forced:Ic},{stringify:function(g,t,A){for(var e=0,C=arguments.length,I=Hl(C);e<C;e++)I[e]=arguments[e];var i=Ql(Kl,null,I);return"string"==typeof i?$l(i,tc,Cc):i}});var ic=U,oc=s;ic.JSON||(ic.JSON={stringify:JSON.stringify});var nc=function(g,t,A){return oc(ic.JSON.stringify,null,arguments)},rc=nc,sc=e.TypeError,ac=bA,dc=e,hc=s,lc=p,cc=de,uc=function(g,t){if(g<t)throw sc("Not enough arguments");return g},pc=/MSIE .\./.test(gg),fc=dc.Function,vc=function(g){return function(t,A){var e=uc(arguments.length,1)>2,C=lc(t)?t:fc(t),I=e?cc(arguments,2):void 0;return g(e?function(){hc(C,this,I)}:C,A)}};ac({global:!0,bind:!0,forced:pc},{setTimeout:vc(dc.setTimeout),setInterval:vc(dc.setInterval)});var yc=U.setTimeout,mc=Gg,bc=DA,wc=MA,kc=function(g){for(var t=mc(this),A=wc(t),e=arguments.length,C=bc(e>1?arguments[1]:void 0,A),I=e>2?arguments[2]:void 0,i=void 0===I?A:bc(I,A);i>C;)t[C++]=g;return t};bA({target:"Array",proto:!0},{fill:kc});var xc,Ec=Oe("Array").fill,Oc=$,Tc=Ec,Dc=Array.prototype,Nc=function(g){var t=g.fill;return g===Dc||Oc(Dc,g)&&t===Dc.fill?Tc:t},Rc=Nc;
+/*! Hammer.JS - v2.0.17-rc - 2019-12-16
+	 * http://naver.github.io/egjs
+	 *
+	 * Forked By Naver egjs
+	 * Copyright (c) hammerjs
+	 * Licensed under the MIT license */
+function Pc(){return Pc=Object.assign||function(g){for(var t=1;t<arguments.length;t++){var A=arguments[t];for(var e in A)Object.prototype.hasOwnProperty.call(A,e)&&(g[e]=A[e])}return g},Pc.apply(this,arguments)}function Mc(g,t){g.prototype=Object.create(t.prototype),g.prototype.constructor=g,g.__proto__=t}function Bc(g){if(void 0===g)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return g}xc="function"!=typeof Object.assign?function(g){if(null==g)throw new TypeError("Cannot convert undefined or null to object");for(var t=Object(g),A=1;A<arguments.length;A++){var e=arguments[A];if(null!=e)for(var C in e)e.hasOwnProperty(C)&&(t[C]=e[C])}return t}:Object.assign;var zc,Zc=xc,Sc=["","webkit","Moz","MS","ms","o"],Fc="undefined"==typeof document?{style:{}}:document.createElement("div"),Gc=Math.round,Lc=Math.abs,jc=Date.now;function Vc(g,t){for(var A,e,C=t[0].toUpperCase()+t.slice(1),I=0;I<Sc.length;){if((e=(A=Sc[I])?A+C:t)in g)return e;I++}}zc="undefined"==typeof window?{}:window;var Yc=Vc(Fc.style,"touchAction"),Wc=void 0!==Yc;var Qc="compute",_c="auto",Uc="manipulation",Hc="none",Kc="pan-x",Xc="pan-y",Jc=function(){if(!Wc)return!1;var g={},t=zc.CSS&&zc.CSS.supports;return["auto","manipulation","pan-y","pan-x","pan-x pan-y","none"].forEach((function(A){return g[A]=!t||zc.CSS.supports("touch-action",A)})),g}(),qc="ontouchstart"in zc,$c=void 0!==Vc(zc,"PointerEvent"),gu=qc&&/mobile|tablet|ip(ad|hone|od)|android/i.test(navigator.userAgent),tu="touch",Au="mouse",eu=16,Cu=24,Iu=["x","y"],iu=["clientX","clientY"];function ou(g,t,A){var e;if(g)if(g.forEach)g.forEach(t,A);else if(void 0!==g.length)for(e=0;e<g.length;)t.call(A,g[e],e,g),e++;else for(e in g)g.hasOwnProperty(e)&&t.call(A,g[e],e,g)}function nu(g,t){return"function"==typeof g?g.apply(t&&t[0]||void 0,t):g}function ru(g,t){return g.indexOf(t)>-1}var su=function(){function g(g,t){this.manager=g,this.set(t)}var t=g.prototype;return t.set=function(g){g===Qc&&(g=this.compute()),Wc&&this.manager.element.style&&Jc[g]&&(this.manager.element.style[Yc]=g),this.actions=g.toLowerCase().trim()},t.update=function(){this.set(this.manager.options.touchAction)},t.compute=function(){var g=[];return ou(this.manager.recognizers,(function(t){nu(t.options.enable,[t])&&(g=g.concat(t.getTouchAction()))})),function(g){if(ru(g,Hc))return Hc;var t=ru(g,Kc),A=ru(g,Xc);return t&&A?Hc:t||A?t?Kc:Xc:ru(g,Uc)?Uc:_c}(g.join(" "))},t.preventDefaults=function(g){var t=g.srcEvent,A=g.offsetDirection;if(this.manager.session.prevented)t.preventDefault();else{var e=this.actions,C=ru(e,Hc)&&!Jc.none,I=ru(e,Xc)&&!Jc["pan-y"],i=ru(e,Kc)&&!Jc["pan-x"];if(C){var o=1===g.pointers.length,n=g.distance<2,r=g.deltaTime<250;if(o&&n&&r)return}if(!i||!I)return C||I&&6&A||i&&A&Cu?this.preventSrc(t):void 0}},t.preventSrc=function(g){this.manager.session.prevented=!0,g.preventDefault()},g}();function au(g,t){for(;g;){if(g===t)return!0;g=g.parentNode}return!1}function du(g){var t=g.length;if(1===t)return{x:Gc(g[0].clientX),y:Gc(g[0].clientY)};for(var A=0,e=0,C=0;C<t;)A+=g[C].clientX,e+=g[C].clientY,C++;return{x:Gc(A/t),y:Gc(e/t)}}function hu(g){for(var t=[],A=0;A<g.pointers.length;)t[A]={clientX:Gc(g.pointers[A].clientX),clientY:Gc(g.pointers[A].clientY)},A++;return{timeStamp:jc(),pointers:t,center:du(t),deltaX:g.deltaX,deltaY:g.deltaY}}function lu(g,t,A){A||(A=Iu);var e=t[A[0]]-g[A[0]],C=t[A[1]]-g[A[1]];return Math.sqrt(e*e+C*C)}function cu(g,t,A){A||(A=Iu);var e=t[A[0]]-g[A[0]],C=t[A[1]]-g[A[1]];return 180*Math.atan2(C,e)/Math.PI}function uu(g,t){return g===t?1:Lc(g)>=Lc(t)?g<0?2:4:t<0?8:eu}function pu(g,t,A){return{x:t/g||0,y:A/g||0}}function fu(g,t){var A=g.session,e=t.pointers,C=e.length;A.firstInput||(A.firstInput=hu(t)),C>1&&!A.firstMultiple?A.firstMultiple=hu(t):1===C&&(A.firstMultiple=!1);var I=A.firstInput,i=A.firstMultiple,o=i?i.center:I.center,n=t.center=du(e);t.timeStamp=jc(),t.deltaTime=t.timeStamp-I.timeStamp,t.angle=cu(o,n),t.distance=lu(o,n),function(g,t){var A=t.center,e=g.offsetDelta||{},C=g.prevDelta||{},I=g.prevInput||{};1!==t.eventType&&4!==I.eventType||(C=g.prevDelta={x:I.deltaX||0,y:I.deltaY||0},e=g.offsetDelta={x:A.x,y:A.y}),t.deltaX=C.x+(A.x-e.x),t.deltaY=C.y+(A.y-e.y)}(A,t),t.offsetDirection=uu(t.deltaX,t.deltaY);var r,s,a=pu(t.deltaTime,t.deltaX,t.deltaY);t.overallVelocityX=a.x,t.overallVelocityY=a.y,t.overallVelocity=Lc(a.x)>Lc(a.y)?a.x:a.y,t.scale=i?(r=i.pointers,lu((s=e)[0],s[1],iu)/lu(r[0],r[1],iu)):1,t.rotation=i?function(g,t){return cu(t[1],t[0],iu)+cu(g[1],g[0],iu)}(i.pointers,e):0,t.maxPointers=A.prevInput?t.pointers.length>A.prevInput.maxPointers?t.pointers.length:A.prevInput.maxPointers:t.pointers.length,function(g,t){var A,e,C,I,i=g.lastInterval||t,o=t.timeStamp-i.timeStamp;if(8!==t.eventType&&(o>25||void 0===i.velocity)){var n=t.deltaX-i.deltaX,r=t.deltaY-i.deltaY,s=pu(o,n,r);e=s.x,C=s.y,A=Lc(s.x)>Lc(s.y)?s.x:s.y,I=uu(n,r),g.lastInterval=t}else A=i.velocity,e=i.velocityX,C=i.velocityY,I=i.direction;t.velocity=A,t.velocityX=e,t.velocityY=C,t.direction=I}(A,t);var d,h=g.element,l=t.srcEvent;au(d=l.composedPath?l.composedPath()[0]:l.path?l.path[0]:l.target,h)&&(h=d),t.target=h}function vu(g,t,A){var e=A.pointers.length,C=A.changedPointers.length,I=1&t&&e-C==0,i=12&t&&e-C==0;A.isFirst=!!I,A.isFinal=!!i,I&&(g.session={}),A.eventType=t,fu(g,A),g.emit("hammer.input",A),g.recognize(A),g.session.prevInput=A}function yu(g){return g.trim().split(/\s+/g)}function mu(g,t,A){ou(yu(t),(function(t){g.addEventListener(t,A,!1)}))}function bu(g,t,A){ou(yu(t),(function(t){g.removeEventListener(t,A,!1)}))}function wu(g){var t=g.ownerDocument||g;return t.defaultView||t.parentWindow||window}var ku=function(){function g(g,t){var A=this;this.manager=g,this.callback=t,this.element=g.element,this.target=g.options.inputTarget,this.domHandler=function(t){nu(g.options.enable,[g])&&A.handler(t)},this.init()}var t=g.prototype;return t.handler=function(){},t.init=function(){this.evEl&&mu(this.element,this.evEl,this.domHandler),this.evTarget&&mu(this.target,this.evTarget,this.domHandler),this.evWin&&mu(wu(this.element),this.evWin,this.domHandler)},t.destroy=function(){this.evEl&&bu(this.element,this.evEl,this.domHandler),this.evTarget&&bu(this.target,this.evTarget,this.domHandler),this.evWin&&bu(wu(this.element),this.evWin,this.domHandler)},g}();function xu(g,t,A){if(g.indexOf&&!A)return g.indexOf(t);for(var e=0;e<g.length;){if(A&&g[e][A]==t||!A&&g[e]===t)return e;e++}return-1}var Eu={pointerdown:1,pointermove:2,pointerup:4,pointercancel:8,pointerout:8},Ou={2:tu,3:"pen",4:Au,5:"kinect"},Tu="pointerdown",Du="pointermove pointerup pointercancel";zc.MSPointerEvent&&!zc.PointerEvent&&(Tu="MSPointerDown",Du="MSPointerMove MSPointerUp MSPointerCancel");var Nu=function(g){function t(){var A,e=t.prototype;return e.evEl=Tu,e.evWin=Du,(A=g.apply(this,arguments)||this).store=A.manager.session.pointerEvents=[],A}return Mc(t,g),t.prototype.handler=function(g){var t=this.store,A=!1,e=g.type.toLowerCase().replace("ms",""),C=Eu[e],I=Ou[g.pointerType]||g.pointerType,i=I===tu,o=xu(t,g.pointerId,"pointerId");1&C&&(0===g.button||i)?o<0&&(t.push(g),o=t.length-1):12&C&&(A=!0),o<0||(t[o]=g,this.callback(this.manager,C,{pointers:t,changedPointers:[g],pointerType:I,srcEvent:g}),A&&t.splice(o,1))},t}(ku);function Ru(g){return Array.prototype.slice.call(g,0)}function Pu(g,t,A){for(var e=[],C=[],I=0;I<g.length;){var i=t?g[I][t]:g[I];xu(C,i)<0&&e.push(g[I]),C[I]=i,I++}return A&&(e=t?e.sort((function(g,A){return g[t]>A[t]})):e.sort()),e}var Mu={touchstart:1,touchmove:2,touchend:4,touchcancel:8},Bu="touchstart touchmove touchend touchcancel",zu=function(g){function t(){var A;return t.prototype.evTarget=Bu,(A=g.apply(this,arguments)||this).targetIds={},A}return Mc(t,g),t.prototype.handler=function(g){var t=Mu[g.type],A=Zu.call(this,g,t);A&&this.callback(this.manager,t,{pointers:A[0],changedPointers:A[1],pointerType:tu,srcEvent:g})},t}(ku);function Zu(g,t){var A,e,C=Ru(g.touches),I=this.targetIds;if(3&t&&1===C.length)return I[C[0].identifier]=!0,[C,C];var i=Ru(g.changedTouches),o=[],n=this.target;if(e=C.filter((function(g){return au(g.target,n)})),1===t)for(A=0;A<e.length;)I[e[A].identifier]=!0,A++;for(A=0;A<i.length;)I[i[A].identifier]&&o.push(i[A]),12&t&&delete I[i[A].identifier],A++;return o.length?[Pu(e.concat(o),"identifier",!0),o]:void 0}var Su={mousedown:1,mousemove:2,mouseup:4},Fu="mousedown",Gu="mousemove mouseup",Lu=function(g){function t(){var A,e=t.prototype;return e.evEl=Fu,e.evWin=Gu,(A=g.apply(this,arguments)||this).pressed=!1,A}return Mc(t,g),t.prototype.handler=function(g){var t=Su[g.type];1&t&&0===g.button&&(this.pressed=!0),2&t&&1!==g.which&&(t=4),this.pressed&&(4&t&&(this.pressed=!1),this.callback(this.manager,t,{pointers:[g],changedPointers:[g],pointerType:Au,srcEvent:g}))},t}(ku);function ju(g){var t=g.changedPointers[0];if(t.identifier===this.primaryTouch){var A={x:t.clientX,y:t.clientY},e=this.lastTouches;this.lastTouches.push(A);setTimeout((function(){var g=e.indexOf(A);g>-1&&e.splice(g,1)}),2500)}}function Vu(g,t){1&g?(this.primaryTouch=t.changedPointers[0].identifier,ju.call(this,t)):12&g&&ju.call(this,t)}function Yu(g){for(var t=g.srcEvent.clientX,A=g.srcEvent.clientY,e=0;e<this.lastTouches.length;e++){var C=this.lastTouches[e],I=Math.abs(t-C.x),i=Math.abs(A-C.y);if(I<=25&&i<=25)return!0}return!1}var Wu=function(){return function(g){function t(t,A){var e;return(e=g.call(this,t,A)||this).handler=function(g,t,A){var C=A.pointerType===tu,I=A.pointerType===Au;if(!(I&&A.sourceCapabilities&&A.sourceCapabilities.firesTouchEvents)){if(C)Vu.call(Bc(Bc(e)),t,A);else if(I&&Yu.call(Bc(Bc(e)),A))return;e.callback(g,t,A)}},e.touch=new zu(e.manager,e.handler),e.mouse=new Lu(e.manager,e.handler),e.primaryTouch=null,e.lastTouches=[],e}return Mc(t,g),t.prototype.destroy=function(){this.touch.destroy(),this.mouse.destroy()},t}(ku)}();function Qu(g,t,A){return!!Array.isArray(g)&&(ou(g,A[t],A),!0)}var _u=32,Uu=1;function Hu(g,t){var A=t.manager;return A?A.get(g):g}function Ku(g){return 16&g?"cancel":8&g?"end":4&g?"move":2&g?"start":""}var Xu=function(){function g(g){void 0===g&&(g={}),this.options=Pc({enable:!0},g),this.id=Uu++,this.manager=null,this.state=1,this.simultaneous={},this.requireFail=[]}var t=g.prototype;return t.set=function(g){return Zc(this.options,g),this.manager&&this.manager.touchAction.update(),this},t.recognizeWith=function(g){if(Qu(g,"recognizeWith",this))return this;var t=this.simultaneous;return t[(g=Hu(g,this)).id]||(t[g.id]=g,g.recognizeWith(this)),this},t.dropRecognizeWith=function(g){return Qu(g,"dropRecognizeWith",this)||(g=Hu(g,this),delete this.simultaneous[g.id]),this},t.requireFailure=function(g){if(Qu(g,"requireFailure",this))return this;var t=this.requireFail;return-1===xu(t,g=Hu(g,this))&&(t.push(g),g.requireFailure(this)),this},t.dropRequireFailure=function(g){if(Qu(g,"dropRequireFailure",this))return this;g=Hu(g,this);var t=xu(this.requireFail,g);return t>-1&&this.requireFail.splice(t,1),this},t.hasRequireFailures=function(){return this.requireFail.length>0},t.canRecognizeWith=function(g){return!!this.simultaneous[g.id]},t.emit=function(g){var t=this,A=this.state;function e(A){t.manager.emit(A,g)}A<8&&e(t.options.event+Ku(A)),e(t.options.event),g.additionalEvent&&e(g.additionalEvent),A>=8&&e(t.options.event+Ku(A))},t.tryEmit=function(g){if(this.canEmit())return this.emit(g);this.state=_u},t.canEmit=function(){for(var g=0;g<this.requireFail.length;){if(!(33&this.requireFail[g].state))return!1;g++}return!0},t.recognize=function(g){var t=Zc({},g);if(!nu(this.options.enable,[this,t]))return this.reset(),void(this.state=_u);56&this.state&&(this.state=1),this.state=this.process(t),30&this.state&&this.tryEmit(t)},t.process=function(g){},t.getTouchAction=function(){},t.reset=function(){},g}(),Ju=function(g){function t(t){var A;return void 0===t&&(t={}),(A=g.call(this,Pc({event:"tap",pointers:1,taps:1,interval:300,time:250,threshold:9,posThreshold:10},t))||this).pTime=!1,A.pCenter=!1,A._timer=null,A._input=null,A.count=0,A}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){return[Uc]},A.process=function(g){var t=this,A=this.options,e=g.pointers.length===A.pointers,C=g.distance<A.threshold,I=g.deltaTime<A.time;if(this.reset(),1&g.eventType&&0===this.count)return this.failTimeout();if(C&&I&&e){if(4!==g.eventType)return this.failTimeout();var i=!this.pTime||g.timeStamp-this.pTime<A.interval,o=!this.pCenter||lu(this.pCenter,g.center)<A.posThreshold;if(this.pTime=g.timeStamp,this.pCenter=g.center,o&&i?this.count+=1:this.count=1,this._input=g,0===this.count%A.taps)return this.hasRequireFailures()?(this._timer=setTimeout((function(){t.state=8,t.tryEmit()}),A.interval),2):8}return _u},A.failTimeout=function(){var g=this;return this._timer=setTimeout((function(){g.state=_u}),this.options.interval),_u},A.reset=function(){clearTimeout(this._timer)},A.emit=function(){8===this.state&&(this._input.tapCount=this.count,this.manager.emit(this.options.event,this._input))},t}(Xu),qu=function(g){function t(t){return void 0===t&&(t={}),g.call(this,Pc({pointers:1},t))||this}Mc(t,g);var A=t.prototype;return A.attrTest=function(g){var t=this.options.pointers;return 0===t||g.pointers.length===t},A.process=function(g){var t=this.state,A=g.eventType,e=6&t,C=this.attrTest(g);return e&&(8&A||!C)?16|t:e||C?4&A?8|t:2&t?4|t:2:_u},t}(Xu);function $u(g){return g===eu?"down":8===g?"up":2===g?"left":4===g?"right":""}var gp=function(g){function t(t){var A;return void 0===t&&(t={}),(A=g.call(this,Pc({event:"pan",threshold:10,pointers:1,direction:30},t))||this).pX=null,A.pY=null,A}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){var g=this.options.direction,t=[];return 6&g&&t.push(Xc),g&Cu&&t.push(Kc),t},A.directionTest=function(g){var t=this.options,A=!0,e=g.distance,C=g.direction,I=g.deltaX,i=g.deltaY;return C&t.direction||(6&t.direction?(C=0===I?1:I<0?2:4,A=I!==this.pX,e=Math.abs(g.deltaX)):(C=0===i?1:i<0?8:eu,A=i!==this.pY,e=Math.abs(g.deltaY))),g.direction=C,A&&e>t.threshold&&C&t.direction},A.attrTest=function(g){return qu.prototype.attrTest.call(this,g)&&(2&this.state||!(2&this.state)&&this.directionTest(g))},A.emit=function(t){this.pX=t.deltaX,this.pY=t.deltaY;var A=$u(t.direction);A&&(t.additionalEvent=this.options.event+A),g.prototype.emit.call(this,t)},t}(qu),tp=function(g){function t(t){return void 0===t&&(t={}),g.call(this,Pc({event:"swipe",threshold:10,velocity:.3,direction:30,pointers:1},t))||this}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){return gp.prototype.getTouchAction.call(this)},A.attrTest=function(t){var A,e=this.options.direction;return 30&e?A=t.overallVelocity:6&e?A=t.overallVelocityX:e&Cu&&(A=t.overallVelocityY),g.prototype.attrTest.call(this,t)&&e&t.offsetDirection&&t.distance>this.options.threshold&&t.maxPointers===this.options.pointers&&Lc(A)>this.options.velocity&&4&t.eventType},A.emit=function(g){var t=$u(g.offsetDirection);t&&this.manager.emit(this.options.event+t,g),this.manager.emit(this.options.event,g)},t}(qu),Ap=function(g){function t(t){return void 0===t&&(t={}),g.call(this,Pc({event:"pinch",threshold:0,pointers:2},t))||this}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){return[Hc]},A.attrTest=function(t){return g.prototype.attrTest.call(this,t)&&(Math.abs(t.scale-1)>this.options.threshold||2&this.state)},A.emit=function(t){if(1!==t.scale){var A=t.scale<1?"in":"out";t.additionalEvent=this.options.event+A}g.prototype.emit.call(this,t)},t}(qu),ep=function(g){function t(t){return void 0===t&&(t={}),g.call(this,Pc({event:"rotate",threshold:0,pointers:2},t))||this}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){return[Hc]},A.attrTest=function(t){return g.prototype.attrTest.call(this,t)&&(Math.abs(t.rotation)>this.options.threshold||2&this.state)},t}(qu),Cp=function(g){function t(t){var A;return void 0===t&&(t={}),(A=g.call(this,Pc({event:"press",pointers:1,time:251,threshold:9},t))||this)._timer=null,A._input=null,A}Mc(t,g);var A=t.prototype;return A.getTouchAction=function(){return[_c]},A.process=function(g){var t=this,A=this.options,e=g.pointers.length===A.pointers,C=g.distance<A.threshold,I=g.deltaTime>A.time;if(this._input=g,!C||!e||12&g.eventType&&!I)this.reset();else if(1&g.eventType)this.reset(),this._timer=setTimeout((function(){t.state=8,t.tryEmit()}),A.time);else if(4&g.eventType)return 8;return _u},A.reset=function(){clearTimeout(this._timer)},A.emit=function(g){8===this.state&&(g&&4&g.eventType?this.manager.emit(this.options.event+"up",g):(this._input.timeStamp=jc(),this.manager.emit(this.options.event,this._input)))},t}(Xu),Ip={domEvents:!1,touchAction:Qc,enable:!0,inputTarget:null,inputClass:null,cssProps:{userSelect:"none",touchSelect:"none",touchCallout:"none",contentZooming:"none",userDrag:"none",tapHighlightColor:"rgba(0,0,0,0)"}},ip=[[ep,{enable:!1}],[Ap,{enable:!1},["rotate"]],[tp,{direction:6}],[gp,{direction:6},["swipe"]],[Ju],[Ju,{event:"doubletap",taps:2},["tap"]],[Cp]];function op(g,t){var A,e=g.element;e.style&&(ou(g.options.cssProps,(function(C,I){A=Vc(e.style,I),t?(g.oldCssProps[A]=e.style[A],e.style[A]=C):e.style[A]=g.oldCssProps[A]||""})),t||(g.oldCssProps={}))}var np=function(){function g(g,t){var A,e=this;this.options=Zc({},Ip,t||{}),this.options.inputTarget=this.options.inputTarget||g,this.handlers={},this.session={},this.recognizers=[],this.oldCssProps={},this.element=g,this.input=new((A=this).options.inputClass||($c?Nu:gu?zu:qc?Wu:Lu))(A,vu),this.touchAction=new su(this,this.options.touchAction),op(this,!0),ou(this.options.recognizers,(function(g){var t=e.add(new g[0](g[1]));g[2]&&t.recognizeWith(g[2]),g[3]&&t.requireFailure(g[3])}),this)}var t=g.prototype;return t.set=function(g){return Zc(this.options,g),g.touchAction&&this.touchAction.update(),g.inputTarget&&(this.input.destroy(),this.input.target=g.inputTarget,this.input.init()),this},t.stop=function(g){this.session.stopped=g?2:1},t.recognize=function(g){var t=this.session;if(!t.stopped){var A;this.touchAction.preventDefaults(g);var e=this.recognizers,C=t.curRecognizer;(!C||C&&8&C.state)&&(t.curRecognizer=null,C=null);for(var I=0;I<e.length;)A=e[I],2===t.stopped||C&&A!==C&&!A.canRecognizeWith(C)?A.reset():A.recognize(g),!C&&14&A.state&&(t.curRecognizer=A,C=A),I++}},t.get=function(g){if(g instanceof Xu)return g;for(var t=this.recognizers,A=0;A<t.length;A++)if(t[A].options.event===g)return t[A];return null},t.add=function(g){if(Qu(g,"add",this))return this;var t=this.get(g.options.event);return t&&this.remove(t),this.recognizers.push(g),g.manager=this,this.touchAction.update(),g},t.remove=function(g){if(Qu(g,"remove",this))return this;var t=this.get(g);if(g){var A=this.recognizers,e=xu(A,t);-1!==e&&(A.splice(e,1),this.touchAction.update())}return this},t.on=function(g,t){if(void 0===g||void 0===t)return this;var A=this.handlers;return ou(yu(g),(function(g){A[g]=A[g]||[],A[g].push(t)})),this},t.off=function(g,t){if(void 0===g)return this;var A=this.handlers;return ou(yu(g),(function(g){t?A[g]&&A[g].splice(xu(A[g],t),1):delete A[g]})),this},t.emit=function(g,t){this.options.domEvents&&function(g,t){var A=document.createEvent("Event");A.initEvent(g,!0,!0),A.gesture=t,t.target.dispatchEvent(A)}(g,t);var A=this.handlers[g]&&this.handlers[g].slice();if(A&&A.length){t.type=g,t.preventDefault=function(){t.srcEvent.preventDefault()};for(var e=0;e<A.length;)A[e](t),e++}},t.destroy=function(){this.element&&op(this,!1),this.handlers={},this.session={},this.input.destroy(),this.element=null},g}(),rp={touchstart:1,touchmove:2,touchend:4,touchcancel:8},sp="touchstart",ap="touchstart touchmove touchend touchcancel",dp=function(g){function t(){var A,e=t.prototype;return e.evTarget=sp,e.evWin=ap,(A=g.apply(this,arguments)||this).started=!1,A}return Mc(t,g),t.prototype.handler=function(g){var t=rp[g.type];if(1===t&&(this.started=!0),this.started){var A=hp.call(this,g,t);12&t&&A[0].length-A[1].length==0&&(this.started=!1),this.callback(this.manager,t,{pointers:A[0],changedPointers:A[1],pointerType:tu,srcEvent:g})}},t}(ku);function hp(g,t){var A=Ru(g.touches),e=Ru(g.changedTouches);return 12&t&&(A=Pu(A.concat(e),"identifier",!0)),[A,e]}function lp(g,t,A){var e="DEPRECATED METHOD: "+t+"\n"+A+" AT \n";return function(){var t=new Error("get-stack-trace"),A=t&&t.stack?t.stack.replace(/^[^\(]+?[\n$]/gm,"").replace(/^\s+at\s+/gm,"").replace(/^Object.<anonymous>\s*\(/gm,"{anonymous}()@"):"Unknown Stack Trace",C=window.console&&(window.console.warn||window.console.log);return C&&C.call(window.console,e,A),g.apply(this,arguments)}}var cp=lp((function(g,t,A){for(var e=Object.keys(t),C=0;C<e.length;)(!A||A&&void 0===g[e[C]])&&(g[e[C]]=t[e[C]]),C++;return g}),"extend","Use `assign`."),up=lp((function(g,t){return cp(g,t,!0)}),"merge","Use `assign`.");function pp(g,t,A){var e,C=t.prototype;(e=g.prototype=Object.create(C)).constructor=g,e._super=C,A&&Zc(e,A)}function fp(g,t){return function(){return g.apply(t,arguments)}}var vp=function(){var g=function(g,t){return void 0===t&&(t={}),new np(g,Pc({recognizers:ip.concat()},t))};return g.VERSION="2.0.17-rc",g.DIRECTION_ALL=30,g.DIRECTION_DOWN=eu,g.DIRECTION_LEFT=2,g.DIRECTION_RIGHT=4,g.DIRECTION_UP=8,g.DIRECTION_HORIZONTAL=6,g.DIRECTION_VERTICAL=Cu,g.DIRECTION_NONE=1,g.DIRECTION_DOWN=eu,g.INPUT_START=1,g.INPUT_MOVE=2,g.INPUT_END=4,g.INPUT_CANCEL=8,g.STATE_POSSIBLE=1,g.STATE_BEGAN=2,g.STATE_CHANGED=4,g.STATE_ENDED=8,g.STATE_RECOGNIZED=8,g.STATE_CANCELLED=16,g.STATE_FAILED=_u,g.Manager=np,g.Input=ku,g.TouchAction=su,g.TouchInput=zu,g.MouseInput=Lu,g.PointerEventInput=Nu,g.TouchMouseInput=Wu,g.SingleTouchInput=dp,g.Recognizer=Xu,g.AttrRecognizer=qu,g.Tap=Ju,g.Pan=gp,g.Swipe=tp,g.Pinch=Ap,g.Rotate=ep,g.Press=Cp,g.on=mu,g.off=bu,g.each=ou,g.merge=up,g.extend=cp,g.bindFn=fp,g.assign=Zc,g.inherit=pp,g.bindFn=fp,g.prefixed=Vc,g.toArray=Ru,g.inArray=xu,g.uniqueArray=Pu,g.splitStr=yu,g.boolOrFn=nu,g.hasParent=au,g.addEventListeners=mu,g.removeEventListeners=bu,g.defaults=Zc({},Ip,{preset:ip}),g}(),yp=vp;function mp(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return bp(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return bp(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function bp(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}var wp=td("DELETE");function kp(){var g=xp.apply(void 0,arguments);return Op(g),g}function xp(){for(var g=arguments.length,t=new Array(g),A=0;A<g;A++)t[A]=arguments[A];if(t.length<2)return t[0];var e;if(t.length>2)return xp.apply(void 0,od(e=[kp(t[0],t[1])]).call(e,gd(nd(t).call(t,2))));var C,I=t[0],i=t[1],o=mp(rd(i));try{for(o.s();!(C=o.n()).done;){var n=C.value;Object.prototype.propertyIsEnumerable.call(i,n)&&(i[n]===wp?delete I[n]:null===I[n]||null===i[n]||"object"!==$a(I[n])||"object"!==$a(i[n])||sd(I[n])||sd(i[n])?I[n]=Ep(i[n]):I[n]=xp(I[n],i[n]))}}catch(g){o.e(g)}finally{o.f()}return I}function Ep(g){return sd(g)?pd(g).call(g,(function(g){return Ep(g)})):"object"===$a(g)&&null!==g?xp({},g):g}function Op(g){for(var t=0,A=yd(g);t<A.length;t++){var e=A[t];g[e]===wp?delete g[e]:"object"===$a(g[e])&&null!==g[e]&&Op(g[e])}}function Tp(){for(var g=arguments.length,t=new Array(g),A=0;A<g;A++)t[A]=arguments[A];return Dp(t.length?t:[xd()])}function Dp(g){var t=function(){for(var g=Np(),t=g(" "),A=g(" "),e=g(" "),C=0;C<arguments.length;C++)(t-=g(C<0||arguments.length<=C?void 0:arguments[C]))<0&&(t+=1),(A-=g(C<0||arguments.length<=C?void 0:arguments[C]))<0&&(A+=1),(e-=g(C<0||arguments.length<=C?void 0:arguments[C]))<0&&(e+=1);return[t,A,e]}(g),A=Xa(t,3),e=A[0],C=A[1],I=A[2],i=1,o=function(){var g=2091639*e+2.3283064365386963e-10*i;return e=C,C=I,I=g-(i=0|g)};return o.uint32=function(){return 4294967296*o()},o.fract53=function(){return o()+11102230246251565e-32*(2097152*o()|0)},o.algorithm="Alea",o.seed=g,o.version="0.9",o}function Np(){var g=4022871197;return function(t){for(var A=t.toString(),e=0;e<A.length;e++){var C=.02519603282416938*(g+=A.charCodeAt(e));C-=g=C>>>0,g=(C*=g)>>>0,g+=4294967296*(C-=g)}return 2.3283064365386963e-10*(g>>>0)}}var Rp="undefined"!=typeof window?window.Hammer||yp:function(){return function(){var g=function(){};return{on:g,off:g,destroy:g,emit:g,get:function(){return{set:g}}}}()};function Pp(g){var t,A=this;this._cleanupQueue=[],this.active=!1,this._dom={container:g,overlay:document.createElement("div")},this._dom.overlay.classList.add("vis-overlay"),this._dom.container.appendChild(this._dom.overlay),this._cleanupQueue.push((function(){A._dom.overlay.parentNode.removeChild(A._dom.overlay)}));var e=Rp(this._dom.overlay);e.on("tap",Me(t=this._onTapOverlay).call(t,this)),this._cleanupQueue.push((function(){e.destroy()}));var C=["tap","doubletap","press","pinch","pan","panstart","panmove","panend"];Sd(C).call(C,(function(g){e.on(g,(function(g){g.srcEvent.stopPropagation()}))})),document&&document.body&&(this._onClick=function(t){(function(g,t){for(;g;){if(g===t)return!0;g=g.parentNode}return!1})(t.target,g)||A.deactivate()},document.body.addEventListener("click",this._onClick),this._cleanupQueue.push((function(){document.body.removeEventListener("click",A._onClick)}))),this._escListener=function(g){("key"in g?"Escape"===g.key:27===g.keyCode)&&A.deactivate()}}Ve(Pp.prototype),Pp.current=null,Pp.prototype.destroy=function(){var g,t;this.deactivate();var A,e=mp(Ud(g=hh(t=this._cleanupQueue).call(t,0)).call(g));try{for(e.s();!(A=e.n()).done;){(0,A.value)()}}catch(g){e.e(g)}finally{e.f()}},Pp.prototype.activate=function(){Pp.current&&Pp.current.deactivate(),Pp.current=this,this.active=!0,this._dom.overlay.style.display="none",this._dom.container.classList.add("vis-active"),this.emit("change"),this.emit("activate"),document.body.addEventListener("keydown",this._escListener)},Pp.prototype.deactivate=function(){this.active=!1,this._dom.overlay.style.display="block",this._dom.container.classList.remove("vis-active"),document.body.removeEventListener("keydown",this._escListener),this.emit("change"),this.emit("deactivate")},Pp.prototype._onTapOverlay=function(g){this.activate(),g.srcEvent.stopPropagation()};var Mp=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i,Bp=/^#?([a-f\d])([a-f\d])([a-f\d])$/i,zp=/^rgb\( *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *\)$/i,Zp=/^rgba\( *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *(1?\d{1,2}|2[0-4]\d|25[0-5]) *, *([01]|0?\.\d+) *\)$/i;function Sp(g){if(g)for(;!0===g.hasChildNodes();){var t=g.firstChild;t&&(Sp(t),g.removeChild(t))}}function Fp(g){return g instanceof String||"string"==typeof g}function Gp(g){return"object"===$a(g)&&null!==g}function Lp(g,t,A,e){var C=!1;!0===e&&(C=null===t[A]&&void 0!==g[A]),C?delete g[A]:g[A]=t[A]}function jp(g,t){var A=arguments.length>2&&void 0!==arguments[2]&&arguments[2];for(var e in g)if(void 0!==t[e])if(null===t[e]||"object"!==$a(t[e]))Lp(g,t,e,A);else{var C=g[e],I=t[e];Gp(C)&&Gp(I)&&jp(C,I,A)}}function Vp(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(sd(A))throw new TypeError("Arrays are not supported by deepExtend");for(var C=0;C<g.length;C++){var I=g[C];if(Object.prototype.hasOwnProperty.call(A,I))if(A[I]&&A[I].constructor===Object)void 0===t[I]&&(t[I]={}),t[I].constructor===Object?Wp(t[I],A[I],!1,e):Lp(t,A,I,e);else{if(sd(A[I]))throw new TypeError("Arrays are not supported by deepExtend");Lp(t,A,I,e)}}return t}function Yp(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]&&arguments[3];if(sd(A))throw new TypeError("Arrays are not supported by deepExtend");for(var C in A)if(Object.prototype.hasOwnProperty.call(A,C)&&!zh(g).call(g,C))if(A[C]&&A[C].constructor===Object)void 0===t[C]&&(t[C]={}),t[C].constructor===Object?Wp(t[C],A[C]):Lp(t,A,C,e);else if(sd(A[C])){t[C]=[];for(var I=0;I<A[C].length;I++)t[C].push(A[C][I])}else Lp(t,A,C,e);return t}function Wp(g,t){var A=arguments.length>2&&void 0!==arguments[2]&&arguments[2],e=arguments.length>3&&void 0!==arguments[3]&&arguments[3];for(var C in t)if(Object.prototype.hasOwnProperty.call(t,C)||!0===A)if("object"===$a(t[C])&&null!==t[C]&&Lh(t[C])===Object.prototype)void 0===g[C]?g[C]=Wp({},t[C],A):"object"===$a(g[C])&&null!==g[C]&&Lh(g[C])===Object.prototype?Wp(g[C],t[C],A):Lp(g,t,C,e);else if(sd(t[C])){var I;g[C]=nd(I=t[C]).call(I)}else Lp(g,t,C,e);return g}function Qp(g,t){var A;return od(A=[]).call(A,gd(g),[t])}function _p(g){return nd(g).call(g)}function Up(g){return g.getBoundingClientRect().top}function Hp(g,t){if(sd(g))for(var A=g.length,e=0;e<A;e++)t(g[e],e,g);else for(var C in g)Object.prototype.hasOwnProperty.call(g,C)&&t(g[C],C,g)}function Kp(g){var t;switch(g.length){case 3:case 4:return(t=Bp.exec(g))?{r:wl(t[1]+t[1],16),g:wl(t[2]+t[2],16),b:wl(t[3]+t[3],16)}:null;case 6:case 7:return(t=Mp.exec(g))?{r:wl(t[1],16),g:wl(t[2],16),b:wl(t[3],16)}:null;default:return null}}function Xp(g,t){if(zh(g).call(g,"rgba"))return g;if(zh(g).call(g,"rgb")){var A=g.substr(zl(g).call(g,"(")+1).replace(")","").split(",");return"rgba("+A[0]+","+A[1]+","+A[2]+","+t+")"}var e=Kp(g);return null==e?g:"rgba("+e.r+","+e.g+","+e.b+","+t+")"}function Jp(g,t,A){var e;return"#"+nd(e=((1<<24)+(g<<16)+(t<<8)+A).toString(16)).call(e,1)}function qp(g,t){if(Fp(g)){var A=g;if(ef(A)){var e,C=pd(e=A.substr(4).substr(0,A.length-5).split(",")).call(e,(function(g){return wl(g)}));A=Jp(C[0],C[1],C[2])}if(!0===Af(A)){var I=function(g){var t=Kp(g);if(!t)throw new TypeError("'".concat(g,"' is not a valid color."));return $p(t.r,t.g,t.b)}(A),i={h:I.h,s:.8*I.s,v:Math.min(1,1.02*I.v)},o={h:I.h,s:Math.min(1,1.25*I.s),v:.8*I.v},n=tf(o.h,o.s,o.v),r=tf(i.h,i.s,i.v);return{background:A,border:n,highlight:{background:r,border:n},hover:{background:r,border:n}}}return{background:A,border:A,highlight:{background:A,border:A},hover:{background:A,border:A}}}return t?{background:g.background||t.background,border:g.border||t.border,highlight:Fp(g.highlight)?{border:g.highlight,background:g.highlight}:{background:g.highlight&&g.highlight.background||t.highlight.background,border:g.highlight&&g.highlight.border||t.highlight.border},hover:Fp(g.hover)?{border:g.hover,background:g.hover}:{border:g.hover&&g.hover.border||t.hover.border,background:g.hover&&g.hover.background||t.hover.background}}:{background:g.background||void 0,border:g.border||void 0,highlight:Fp(g.highlight)?{border:g.highlight,background:g.highlight}:{background:g.highlight&&g.highlight.background||void 0,border:g.highlight&&g.highlight.border||void 0},hover:Fp(g.hover)?{border:g.hover,background:g.hover}:{border:g.hover&&g.hover.border||void 0,background:g.hover&&g.hover.background||void 0}}}function $p(g,t,A){g/=255,t/=255,A/=255;var e=Math.min(g,Math.min(t,A)),C=Math.max(g,Math.max(t,A));return e===C?{h:0,s:0,v:e}:{h:60*((g===e?3:A===e?1:5)-(g===e?t-A:A===e?g-t:A-g)/(C-e))/360,s:(C-e)/C,v:C}}function gf(g,t,A){var e,C,I,i=Math.floor(6*g),o=6*g-i,n=A*(1-t),r=A*(1-o*t),s=A*(1-(1-o)*t);switch(i%6){case 0:e=A,C=s,I=n;break;case 1:e=r,C=A,I=n;break;case 2:e=n,C=A,I=s;break;case 3:e=n,C=r,I=A;break;case 4:e=s,C=n,I=A;break;case 5:e=A,C=n,I=r}return{r:Math.floor(255*e),g:Math.floor(255*C),b:Math.floor(255*I)}}function tf(g,t,A){var e=gf(g,t,A);return Jp(e.r,e.g,e.b)}function Af(g){return/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(g)}function ef(g){return zp.test(g)}function Cf(g){return Zp.test(g)}function If(g){if(null===g||"object"!==$a(g))return null;if(g instanceof Element)return g;var t=Vl(g);for(var A in g)Object.prototype.hasOwnProperty.call(g,A)&&"object"==$a(g[A])&&(t[A]=If(g[A]));return t}function of(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},C=function(g){return null!=g},I=function(g){return null!==g&&"object"===$a(g)},i=function(g){for(var t in g)if(Object.prototype.hasOwnProperty.call(g,t))return!1;return!0};if(!I(g))throw new Error("Parameter mergeTarget must be an object");if(!I(t))throw new Error("Parameter options must be an object");if(!C(A))throw new Error("Parameter option must have a value");if(!I(e))throw new Error("Parameter globalOptions must be an object");var o=function(g,t,A){I(g[A])||(g[A]={});var e=t[A],C=g[A];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(C[i]=e[i])},n=t[A],r=I(e)&&!i(e),s=r?e[A]:void 0,a=s?s.enabled:void 0;if(void 0!==n){if("boolean"==typeof n)return I(g[A])||(g[A]={}),void(g[A].enabled=n);if(null===n&&!I(g[A])){if(!C(s))return;g[A]=Vl(s)}if(I(n)){var d=!0;void 0!==n.enabled?d=n.enabled:void 0!==a&&(d=s.enabled),o(g,t,A),g[A].enabled=d}}}var nf={linear:function(g){return g},easeInQuad:function(g){return g*g},easeOutQuad:function(g){return g*(2-g)},easeInOutQuad:function(g){return g<.5?2*g*g:(4-2*g)*g-1},easeInCubic:function(g){return g*g*g},easeOutCubic:function(g){return--g*g*g+1},easeInOutCubic:function(g){return g<.5?4*g*g*g:(g-1)*(2*g-2)*(2*g-2)+1},easeInQuart:function(g){return g*g*g*g},easeOutQuart:function(g){return 1- --g*g*g*g},easeInOutQuart:function(g){return g<.5?8*g*g*g*g:1-8*--g*g*g*g},easeInQuint:function(g){return g*g*g*g*g},easeOutQuint:function(g){return 1+--g*g*g*g*g},easeInOutQuint:function(g){return g<.5?16*g*g*g*g*g:1+16*--g*g*g*g*g}};function rf(g,t){var A;sd(t)||(t=[t]);var e,C=mp(g);try{for(C.s();!(e=C.n()).done;){var I=e.value;if(I){A=I[t[0]];for(var i=1;i<t.length;i++)A&&(A=A[t[i]]);if(void 0!==A)break}}}catch(g){C.e(g)}finally{C.f()}return A}var sf={black:"#000000",navy:"#000080",darkblue:"#00008B",mediumblue:"#0000CD",blue:"#0000FF",darkgreen:"#006400",green:"#008000",teal:"#008080",darkcyan:"#008B8B",deepskyblue:"#00BFFF",darkturquoise:"#00CED1",mediumspringgreen:"#00FA9A",lime:"#00FF00",springgreen:"#00FF7F",aqua:"#00FFFF",cyan:"#00FFFF",midnightblue:"#191970",dodgerblue:"#1E90FF",lightseagreen:"#20B2AA",forestgreen:"#228B22",seagreen:"#2E8B57",darkslategray:"#2F4F4F",limegreen:"#32CD32",mediumseagreen:"#3CB371",turquoise:"#40E0D0",royalblue:"#4169E1",steelblue:"#4682B4",darkslateblue:"#483D8B",mediumturquoise:"#48D1CC",indigo:"#4B0082",darkolivegreen:"#556B2F",cadetblue:"#5F9EA0",cornflowerblue:"#6495ED",mediumaquamarine:"#66CDAA",dimgray:"#696969",slateblue:"#6A5ACD",olivedrab:"#6B8E23",slategray:"#708090",lightslategray:"#778899",mediumslateblue:"#7B68EE",lawngreen:"#7CFC00",chartreuse:"#7FFF00",aquamarine:"#7FFFD4",maroon:"#800000",purple:"#800080",olive:"#808000",gray:"#808080",skyblue:"#87CEEB",lightskyblue:"#87CEFA",blueviolet:"#8A2BE2",darkred:"#8B0000",darkmagenta:"#8B008B",saddlebrown:"#8B4513",darkseagreen:"#8FBC8F",lightgreen:"#90EE90",mediumpurple:"#9370D8",darkviolet:"#9400D3",palegreen:"#98FB98",darkorchid:"#9932CC",yellowgreen:"#9ACD32",sienna:"#A0522D",brown:"#A52A2A",darkgray:"#A9A9A9",lightblue:"#ADD8E6",greenyellow:"#ADFF2F",paleturquoise:"#AFEEEE",lightsteelblue:"#B0C4DE",powderblue:"#B0E0E6",firebrick:"#B22222",darkgoldenrod:"#B8860B",mediumorchid:"#BA55D3",rosybrown:"#BC8F8F",darkkhaki:"#BDB76B",silver:"#C0C0C0",mediumvioletred:"#C71585",indianred:"#CD5C5C",peru:"#CD853F",chocolate:"#D2691E",tan:"#D2B48C",lightgrey:"#D3D3D3",palevioletred:"#D87093",thistle:"#D8BFD8",orchid:"#DA70D6",goldenrod:"#DAA520",crimson:"#DC143C",gainsboro:"#DCDCDC",plum:"#DDA0DD",burlywood:"#DEB887",lightcyan:"#E0FFFF",lavender:"#E6E6FA",darksalmon:"#E9967A",violet:"#EE82EE",palegoldenrod:"#EEE8AA",lightcoral:"#F08080",khaki:"#F0E68C",aliceblue:"#F0F8FF",honeydew:"#F0FFF0",azure:"#F0FFFF",sandybrown:"#F4A460",wheat:"#F5DEB3",beige:"#F5F5DC",whitesmoke:"#F5F5F5",mintcream:"#F5FFFA",ghostwhite:"#F8F8FF",salmon:"#FA8072",antiquewhite:"#FAEBD7",linen:"#FAF0E6",lightgoldenrodyellow:"#FAFAD2",oldlace:"#FDF5E6",red:"#FF0000",fuchsia:"#FF00FF",magenta:"#FF00FF",deeppink:"#FF1493",orangered:"#FF4500",tomato:"#FF6347",hotpink:"#FF69B4",coral:"#FF7F50",darkorange:"#FF8C00",lightsalmon:"#FFA07A",orange:"#FFA500",lightpink:"#FFB6C1",pink:"#FFC0CB",gold:"#FFD700",peachpuff:"#FFDAB9",navajowhite:"#FFDEAD",moccasin:"#FFE4B5",bisque:"#FFE4C4",mistyrose:"#FFE4E1",blanchedalmond:"#FFEBCD",papayawhip:"#FFEFD5",lavenderblush:"#FFF0F5",seashell:"#FFF5EE",cornsilk:"#FFF8DC",lemonchiffon:"#FFFACD",floralwhite:"#FFFAF0",snow:"#FFFAFA",yellow:"#FFFF00",lightyellow:"#FFFFE0",ivory:"#FFFFF0",white:"#FFFFFF"},af=function(){function g(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1;Us(this,g),this.pixelRatio=t,this.generated=!1,this.centerCoordinates={x:144.5,y:144.5},this.r=289*.49,this.color={r:255,g:255,b:255,a:1},this.hueCircle=void 0,this.initialColor={r:255,g:255,b:255,a:1},this.previousColor=void 0,this.applied=!1,this.updateCallback=function(){},this.closeCallback=function(){},this._create()}return Xs(g,[{key:"insertTo",value:function(g){void 0!==this.hammer&&(this.hammer.destroy(),this.hammer=void 0),this.container=g,this.container.appendChild(this.frame),this._bindHammer(),this._setSize()}},{key:"setUpdateCallback",value:function(g){if("function"!=typeof g)throw new Error("Function attempted to set as colorPicker update callback is not a function.");this.updateCallback=g}},{key:"setCloseCallback",value:function(g){if("function"!=typeof g)throw new Error("Function attempted to set as colorPicker closing callback is not a function.");this.closeCallback=g}},{key:"_isColorString",value:function(g){if("string"==typeof g)return sf[g]}},{key:"setColor",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if("none"!==g){var A,e=this._isColorString(g);if(void 0!==e&&(g=e),!0===Fp(g)){if(!0===ef(g)){var C=g.substr(4).substr(0,g.length-5).split(",");A={r:C[0],g:C[1],b:C[2],a:1}}else if(!0===Cf(g)){var I=g.substr(5).substr(0,g.length-6).split(",");A={r:I[0],g:I[1],b:I[2],a:I[3]}}else if(!0===Af(g)){var i=Kp(g);A={r:i.r,g:i.g,b:i.b,a:1}}}else if(g instanceof Object&&void 0!==g.r&&void 0!==g.g&&void 0!==g.b){var o=void 0!==g.a?g.a:"1.0";A={r:g.r,g:g.g,b:g.b,a:o}}if(void 0===A)throw new Error("Unknown color passed to the colorPicker. Supported are strings: rgb, hex, rgba. Object: rgb ({r:r,g:g,b:b,[a:a]}). Supplied: "+rc(g));this._setColor(A,t)}}},{key:"show",value:function(){void 0!==this.closeCallback&&(this.closeCallback(),this.closeCallback=void 0),this.applied=!1,this.frame.style.display="block",this._generateHueCircle()}},{key:"_hide",value:function(){var g=this,t=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];!0===t&&(this.previousColor=ae({},this.color)),!0===this.applied&&this.updateCallback(this.initialColor),this.frame.style.display="none",yc((function(){void 0!==g.closeCallback&&(g.closeCallback(),g.closeCallback=void 0)}),0)}},{key:"_save",value:function(){this.updateCallback(this.color),this.applied=!1,this._hide()}},{key:"_apply",value:function(){this.applied=!0,this.updateCallback(this.color),this._updatePicker(this.color)}},{key:"_loadLast",value:function(){void 0!==this.previousColor?this.setColor(this.previousColor,!1):alert("There is no last color to load...")}},{key:"_setColor",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];!0===t&&(this.initialColor=ae({},g)),this.color=g;var A=$p(g.r,g.g,g.b),e=2*Math.PI,C=this.r*A.s,I=this.centerCoordinates.x+C*Math.sin(e*A.h),i=this.centerCoordinates.y+C*Math.cos(e*A.h);this.colorPickerSelector.style.left=I-.5*this.colorPickerSelector.clientWidth+"px",this.colorPickerSelector.style.top=i-.5*this.colorPickerSelector.clientHeight+"px",this._updatePicker(g)}},{key:"_setOpacity",value:function(g){this.color.a=g/100,this._updatePicker(this.color)}},{key:"_setBrightness",value:function(g){var t=$p(this.color.r,this.color.g,this.color.b);t.v=g/100;var A=gf(t.h,t.s,t.v);A.a=this.color.a,this.color=A,this._updatePicker()}},{key:"_updatePicker",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.color,t=$p(g.r,g.g,g.b),A=this.colorPickerCanvas.getContext("2d");void 0===this.pixelRation&&(this.pixelRatio=(window.devicePixelRatio||1)/(A.webkitBackingStorePixelRatio||A.mozBackingStorePixelRatio||A.msBackingStorePixelRatio||A.oBackingStorePixelRatio||A.backingStorePixelRatio||1)),A.setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0);var e=this.colorPickerCanvas.clientWidth,C=this.colorPickerCanvas.clientHeight;A.clearRect(0,0,e,C),A.putImageData(this.hueCircle,0,0),A.fillStyle="rgba(0,0,0,"+(1-t.v)+")",A.circle(this.centerCoordinates.x,this.centerCoordinates.y,this.r),Rc(A).call(A),this.brightnessRange.value=100*t.v,this.opacityRange.value=100*g.a,this.initialColorDiv.style.backgroundColor="rgba("+this.initialColor.r+","+this.initialColor.g+","+this.initialColor.b+","+this.initialColor.a+")",this.newColorDiv.style.backgroundColor="rgba("+this.color.r+","+this.color.g+","+this.color.b+","+this.color.a+")"}},{key:"_setSize",value:function(){this.colorPickerCanvas.style.width="100%",this.colorPickerCanvas.style.height="100%",this.colorPickerCanvas.width=289*this.pixelRatio,this.colorPickerCanvas.height=289*this.pixelRatio}},{key:"_create",value:function(){var g,t,A,e;if(this.frame=document.createElement("div"),this.frame.className="vis-color-picker",this.colorPickerDiv=document.createElement("div"),this.colorPickerSelector=document.createElement("div"),this.colorPickerSelector.className="vis-selector",this.colorPickerDiv.appendChild(this.colorPickerSelector),this.colorPickerCanvas=document.createElement("canvas"),this.colorPickerDiv.appendChild(this.colorPickerCanvas),this.colorPickerCanvas.getContext){var C=this.colorPickerCanvas.getContext("2d");this.pixelRatio=(window.devicePixelRatio||1)/(C.webkitBackingStorePixelRatio||C.mozBackingStorePixelRatio||C.msBackingStorePixelRatio||C.oBackingStorePixelRatio||C.backingStorePixelRatio||1),this.colorPickerCanvas.getContext("2d").setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0)}else{var I=document.createElement("DIV");I.style.color="red",I.style.fontWeight="bold",I.style.padding="10px",I.innerText="Error: your browser does not support HTML canvas",this.colorPickerCanvas.appendChild(I)}this.colorPickerDiv.className="vis-color",this.opacityDiv=document.createElement("div"),this.opacityDiv.className="vis-opacity",this.brightnessDiv=document.createElement("div"),this.brightnessDiv.className="vis-brightness",this.arrowDiv=document.createElement("div"),this.arrowDiv.className="vis-arrow",this.opacityRange=document.createElement("input");try{this.opacityRange.type="range",this.opacityRange.min="0",this.opacityRange.max="100"}catch(g){}this.opacityRange.value="100",this.opacityRange.className="vis-range",this.brightnessRange=document.createElement("input");try{this.brightnessRange.type="range",this.brightnessRange.min="0",this.brightnessRange.max="100"}catch(g){}this.brightnessRange.value="100",this.brightnessRange.className="vis-range",this.opacityDiv.appendChild(this.opacityRange),this.brightnessDiv.appendChild(this.brightnessRange);var i=this;this.opacityRange.onchange=function(){i._setOpacity(this.value)},this.opacityRange.oninput=function(){i._setOpacity(this.value)},this.brightnessRange.onchange=function(){i._setBrightness(this.value)},this.brightnessRange.oninput=function(){i._setBrightness(this.value)},this.brightnessLabel=document.createElement("div"),this.brightnessLabel.className="vis-label vis-brightness",this.brightnessLabel.innerText="brightness:",this.opacityLabel=document.createElement("div"),this.opacityLabel.className="vis-label vis-opacity",this.opacityLabel.innerText="opacity:",this.newColorDiv=document.createElement("div"),this.newColorDiv.className="vis-new-color",this.newColorDiv.innerText="new",this.initialColorDiv=document.createElement("div"),this.initialColorDiv.className="vis-initial-color",this.initialColorDiv.innerText="initial",this.cancelButton=document.createElement("div"),this.cancelButton.className="vis-button vis-cancel",this.cancelButton.innerText="cancel",this.cancelButton.onclick=Me(g=this._hide).call(g,this,!1),this.applyButton=document.createElement("div"),this.applyButton.className="vis-button vis-apply",this.applyButton.innerText="apply",this.applyButton.onclick=Me(t=this._apply).call(t,this),this.saveButton=document.createElement("div"),this.saveButton.className="vis-button vis-save",this.saveButton.innerText="save",this.saveButton.onclick=Me(A=this._save).call(A,this),this.loadButton=document.createElement("div"),this.loadButton.className="vis-button vis-load",this.loadButton.innerText="load last",this.loadButton.onclick=Me(e=this._loadLast).call(e,this),this.frame.appendChild(this.colorPickerDiv),this.frame.appendChild(this.arrowDiv),this.frame.appendChild(this.brightnessLabel),this.frame.appendChild(this.brightnessDiv),this.frame.appendChild(this.opacityLabel),this.frame.appendChild(this.opacityDiv),this.frame.appendChild(this.newColorDiv),this.frame.appendChild(this.initialColorDiv),this.frame.appendChild(this.cancelButton),this.frame.appendChild(this.applyButton),this.frame.appendChild(this.saveButton),this.frame.appendChild(this.loadButton)}},{key:"_bindHammer",value:function(){var g=this;this.drag={},this.pinch={},this.hammer=new Rp(this.colorPickerCanvas),this.hammer.get("pinch").set({enable:!0}),this.hammer.on("hammer.input",(function(t){t.isFirst&&g._moveSelector(t)})),this.hammer.on("tap",(function(t){g._moveSelector(t)})),this.hammer.on("panstart",(function(t){g._moveSelector(t)})),this.hammer.on("panmove",(function(t){g._moveSelector(t)})),this.hammer.on("panend",(function(t){g._moveSelector(t)}))}},{key:"_generateHueCircle",value:function(){if(!1===this.generated){var g=this.colorPickerCanvas.getContext("2d");void 0===this.pixelRation&&(this.pixelRatio=(window.devicePixelRatio||1)/(g.webkitBackingStorePixelRatio||g.mozBackingStorePixelRatio||g.msBackingStorePixelRatio||g.oBackingStorePixelRatio||g.backingStorePixelRatio||1)),g.setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0);var t,A,e,C,I=this.colorPickerCanvas.clientWidth,i=this.colorPickerCanvas.clientHeight;g.clearRect(0,0,I,i),this.centerCoordinates={x:.5*I,y:.5*i},this.r=.49*I;var o,n=2*Math.PI/360,r=1/this.r;for(e=0;e<360;e++)for(C=0;C<this.r;C++)t=this.centerCoordinates.x+C*Math.sin(n*e),A=this.centerCoordinates.y+C*Math.cos(n*e),o=gf(.002777777777777778*e,C*r,1),g.fillStyle="rgb("+o.r+","+o.g+","+o.b+")",g.fillRect(t-.5,A-.5,2,2);g.strokeStyle="rgba(0,0,0,1)",g.circle(this.centerCoordinates.x,this.centerCoordinates.y,this.r),g.stroke(),this.hueCircle=g.getImageData(0,0,I,i)}this.generated=!0}},{key:"_moveSelector",value:function(g){var t=this.colorPickerDiv.getBoundingClientRect(),A=g.center.x-t.left,e=g.center.y-t.top,C=.5*this.colorPickerDiv.clientHeight,I=.5*this.colorPickerDiv.clientWidth,i=A-I,o=e-C,n=Math.atan2(i,o),r=.98*Math.min(Math.sqrt(i*i+o*o),I),s=Math.cos(n)*r+C,a=Math.sin(n)*r+I;this.colorPickerSelector.style.top=s-.5*this.colorPickerSelector.clientHeight+"px",this.colorPickerSelector.style.left=a-.5*this.colorPickerSelector.clientWidth+"px";var d=n/(2*Math.PI);d=d<0?d+1:d;var h=r/this.r,l=$p(this.color.r,this.color.g,this.color.b);l.h=d,l.s=h;var c=gf(l.h,l.s,l.v);c.a=this.color.a,this.color=c,this.initialColorDiv.style.backgroundColor="rgba("+this.initialColor.r+","+this.initialColor.g+","+this.initialColor.b+","+this.initialColor.a+")",this.newColorDiv.style.backgroundColor="rgba("+this.color.r+","+this.color.g+","+this.color.b+","+this.color.a+")"}}]),g}();function df(){for(var g=arguments.length,t=new Array(g),A=0;A<g;A++)t[A]=arguments[A];if(t.length<1)throw new TypeError("Invalid arguments.");if(1===t.length)return document.createTextNode(t[0]);var e=document.createElement(t[0]);return e.appendChild(df.apply(void 0,gd(nd(t).call(t,1)))),e}var hf,lf=function(){function g(t,A,e){var C=arguments.length>3&&void 0!==arguments[3]?arguments[3]:1,I=arguments.length>4&&void 0!==arguments[4]?arguments[4]:function(){return!1};Us(this,g),this.parent=t,this.changedOptions=[],this.container=A,this.allowCreation=!1,this.hideOption=I,this.options={},this.initialized=!1,this.popupCounter=0,this.defaultOptions={enabled:!1,filter:!0,container:void 0,showButton:!0},ae(this.options,this.defaultOptions),this.configureOptions=e,this.moduleOptions={},this.domElements=[],this.popupDiv={},this.popupLimit=5,this.popupHistory={},this.colorPicker=new af(C),this.wrapper=void 0}return Xs(g,[{key:"setOptions",value:function(g){if(void 0!==g){this.popupHistory={},this._removePopup();var t=!0;if("string"==typeof g)this.options.filter=g;else if(sd(g))this.options.filter=g.join();else if("object"===$a(g)){if(null==g)throw new TypeError("options cannot be null");void 0!==g.container&&(this.options.container=g.container),void 0!==Uh(g)&&(this.options.filter=Uh(g)),void 0!==g.showButton&&(this.options.showButton=g.showButton),void 0!==g.enabled&&(t=g.enabled)}else"boolean"==typeof g?(this.options.filter=!0,t=g):"function"==typeof g&&(this.options.filter=g,t=!0);!1===Uh(this.options)&&(t=!1),this.options.enabled=t}this._clean()}},{key:"setModuleOptions",value:function(g){this.moduleOptions=g,!0===this.options.enabled&&(this._clean(),void 0!==this.options.container&&(this.container=this.options.container),this._create())}},{key:"_create",value:function(){this._clean(),this.changedOptions=[];var g=Uh(this.options),t=0,A=!1;for(var e in this.configureOptions)Object.prototype.hasOwnProperty.call(this.configureOptions,e)&&(this.allowCreation=!1,A=!1,"function"==typeof g?A=(A=g(e,[]))||this._handleObject(this.configureOptions[e],[e],!0):!0!==g&&-1===zl(g).call(g,e)||(A=!0),!1!==A&&(this.allowCreation=!0,t>0&&this._makeItem([]),this._makeHeader(e),this._handleObject(this.configureOptions[e],[e])),t++);this._makeButton(),this._push()}},{key:"_push",value:function(){this.wrapper=document.createElement("div"),this.wrapper.className="vis-configuration-wrapper",this.container.appendChild(this.wrapper);for(var g=0;g<this.domElements.length;g++)this.wrapper.appendChild(this.domElements[g]);this._showPopupIfNeeded()}},{key:"_clean",value:function(){for(var g=0;g<this.domElements.length;g++)this.wrapper.removeChild(this.domElements[g]);void 0!==this.wrapper&&(this.container.removeChild(this.wrapper),this.wrapper=void 0),this.domElements=[],this._removePopup()}},{key:"_getValue",value:function(g){for(var t=this.moduleOptions,A=0;A<g.length;A++){if(void 0===t[g[A]]){t=void 0;break}t=t[g[A]]}return t}},{key:"_makeItem",value:function(g){if(!0===this.allowCreation){var t=document.createElement("div");t.className="vis-configuration vis-config-item vis-config-s"+g.length;for(var A=arguments.length,e=new Array(A>1?A-1:0),C=1;C<A;C++)e[C-1]=arguments[C];return Sd(e).call(e,(function(g){t.appendChild(g)})),this.domElements.push(t),this.domElements.length}return 0}},{key:"_makeHeader",value:function(g){var t=document.createElement("div");t.className="vis-configuration vis-config-header",t.innerText=g,this._makeItem([],t)}},{key:"_makeLabel",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]&&arguments[2],e=document.createElement("div");if(e.className="vis-configuration vis-config-label vis-config-s"+t.length,!0===A){for(;e.firstChild;)e.removeChild(e.firstChild);e.appendChild(df("i","b",g))}else e.innerText=g+":";return e}},{key:"_makeDropdown",value:function(g,t,A){var e=document.createElement("select");e.className="vis-configuration vis-config-select";var C=0;void 0!==t&&-1!==zl(g).call(g,t)&&(C=zl(g).call(g,t));for(var I=0;I<g.length;I++){var i=document.createElement("option");i.value=g[I],I===C&&(i.selected="selected"),i.innerText=g[I],e.appendChild(i)}var o=this;e.onchange=function(){o._update(this.value,A)};var n=this._makeLabel(A[A.length-1],A);this._makeItem(A,n,e)}},{key:"_makeRange",value:function(g,t,A){var e=g[0],C=g[1],I=g[2],i=g[3],o=document.createElement("input");o.className="vis-configuration vis-config-range";try{o.type="range",o.min=C,o.max=I}catch(g){}o.step=i;var n="",r=0;if(void 0!==t){var s=1.2;t<0&&t*s<C?(o.min=Math.ceil(t*s),r=o.min,n="range increased"):t/s<C&&(o.min=Math.ceil(t/s),r=o.min,n="range increased"),t*s>I&&1!==I&&(o.max=Math.ceil(t*s),r=o.max,n="range increased"),o.value=t}else o.value=e;var a=document.createElement("input");a.className="vis-configuration vis-config-rangeinput",a.value=o.value;var d=this;o.onchange=function(){a.value=this.value,d._update(Number(this.value),A)},o.oninput=function(){a.value=this.value};var h=this._makeLabel(A[A.length-1],A),l=this._makeItem(A,h,o,a);""!==n&&this.popupHistory[l]!==r&&(this.popupHistory[l]=r,this._setupPopup(n,l))}},{key:"_makeButton",value:function(){var g=this;if(!0===this.options.showButton){var t=document.createElement("div");t.className="vis-configuration vis-config-button",t.innerText="generate options",t.onclick=function(){g._printOptions()},t.onmouseover=function(){t.className="vis-configuration vis-config-button hover"},t.onmouseout=function(){t.className="vis-configuration vis-config-button"},this.optionsContainer=document.createElement("div"),this.optionsContainer.className="vis-configuration vis-config-option-container",this.domElements.push(this.optionsContainer),this.domElements.push(t)}}},{key:"_setupPopup",value:function(g,t){var A=this;if(!0===this.initialized&&!0===this.allowCreation&&this.popupCounter<this.popupLimit){var e=document.createElement("div");e.id="vis-configuration-popup",e.className="vis-configuration-popup",e.innerText=g,e.onclick=function(){A._removePopup()},this.popupCounter+=1,this.popupDiv={html:e,index:t}}}},{key:"_removePopup",value:function(){void 0!==this.popupDiv.html&&(this.popupDiv.html.parentNode.removeChild(this.popupDiv.html),clearTimeout(this.popupDiv.hideTimeout),clearTimeout(this.popupDiv.deleteTimeout),this.popupDiv={})}},{key:"_showPopupIfNeeded",value:function(){var g=this;if(void 0!==this.popupDiv.html){var t=this.domElements[this.popupDiv.index].getBoundingClientRect();this.popupDiv.html.style.left=t.left+"px",this.popupDiv.html.style.top=t.top-30+"px",document.body.appendChild(this.popupDiv.html),this.popupDiv.hideTimeout=yc((function(){g.popupDiv.html.style.opacity=0}),1500),this.popupDiv.deleteTimeout=yc((function(){g._removePopup()}),1800)}}},{key:"_makeCheckbox",value:function(g,t,A){var e=document.createElement("input");e.type="checkbox",e.className="vis-configuration vis-config-checkbox",e.checked=g,void 0!==t&&(e.checked=t,t!==g&&("object"===$a(g)?t!==g.enabled&&this.changedOptions.push({path:A,value:t}):this.changedOptions.push({path:A,value:t})));var C=this;e.onchange=function(){C._update(this.checked,A)};var I=this._makeLabel(A[A.length-1],A);this._makeItem(A,I,e)}},{key:"_makeTextInput",value:function(g,t,A){var e=document.createElement("input");e.type="text",e.className="vis-configuration vis-config-text",e.value=t,t!==g&&this.changedOptions.push({path:A,value:t});var C=this;e.onchange=function(){C._update(this.value,A)};var I=this._makeLabel(A[A.length-1],A);this._makeItem(A,I,e)}},{key:"_makeColorField",value:function(g,t,A){var e=this,C=g[1],I=document.createElement("div");"none"!==(t=void 0===t?C:t)?(I.className="vis-configuration vis-config-colorBlock",I.style.backgroundColor=t):I.className="vis-configuration vis-config-colorBlock none",t=void 0===t?C:t,I.onclick=function(){e._showColorPicker(t,I,A)};var i=this._makeLabel(A[A.length-1],A);this._makeItem(A,i,I)}},{key:"_showColorPicker",value:function(g,t,A){var e=this;t.onclick=function(){},this.colorPicker.insertTo(t),this.colorPicker.show(),this.colorPicker.setColor(g),this.colorPicker.setUpdateCallback((function(g){var C="rgba("+g.r+","+g.g+","+g.b+","+g.a+")";t.style.backgroundColor=C,e._update(C,A)})),this.colorPicker.setCloseCallback((function(){t.onclick=function(){e._showColorPicker(g,t,A)}}))}},{key:"_handleObject",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],A=arguments.length>2&&void 0!==arguments[2]&&arguments[2],e=!1,C=Uh(this.options),I=!1;for(var i in g)if(Object.prototype.hasOwnProperty.call(g,i)){e=!0;var o=g[i],n=Qp(t,i);if("function"==typeof C&&!1===(e=C(i,t))&&!sd(o)&&"string"!=typeof o&&"boolean"!=typeof o&&o instanceof Object&&(this.allowCreation=!1,e=this._handleObject(o,n,!0),this.allowCreation=!1===A),!1!==e){I=!0;var r=this._getValue(n);if(sd(o))this._handleArray(o,r,n);else if("string"==typeof o)this._makeTextInput(o,r,n);else if("boolean"==typeof o)this._makeCheckbox(o,r,n);else if(o instanceof Object){if(!this.hideOption(t,i,this.moduleOptions))if(void 0!==o.enabled){var s=Qp(n,"enabled"),a=this._getValue(s);if(!0===a){var d=this._makeLabel(i,n,!0);this._makeItem(n,d),I=this._handleObject(o,n)||I}else this._makeCheckbox(o,a,n)}else{var h=this._makeLabel(i,n,!0);this._makeItem(n,h),I=this._handleObject(o,n)||I}}else console.error("dont know how to handle",o,i,n)}}return I}},{key:"_handleArray",value:function(g,t,A){"string"==typeof g[0]&&"color"===g[0]?(this._makeColorField(g,t,A),g[1]!==t&&this.changedOptions.push({path:A,value:t})):"string"==typeof g[0]?(this._makeDropdown(g,t,A),g[0]!==t&&this.changedOptions.push({path:A,value:t})):"number"==typeof g[0]&&(this._makeRange(g,t,A),g[0]!==t&&this.changedOptions.push({path:A,value:Number(t)}))}},{key:"_update",value:function(g,t){var A=this._constructOptions(g,t);this.parent.body&&this.parent.body.emitter&&this.parent.body.emitter.emit&&this.parent.body.emitter.emit("configChange",A),this.initialized=!0,this.parent.setOptions(A)}},{key:"_constructOptions",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},e=A;g="false"!==(g="true"===g||g)&&g;for(var C=0;C<t.length;C++)"global"!==t[C]&&(void 0===e[t[C]]&&(e[t[C]]={}),C!==t.length-1?e=e[t[C]]:e[t[C]]=g);return A}},{key:"_printOptions",value:function(){for(var g=this.getOptions();this.optionsContainer.firstChild;)this.optionsContainer.removeChild(this.optionsContainer.firstChild);this.optionsContainer.appendChild(df("pre","const options = "+rc(g,null,2)))}},{key:"getOptions",value:function(){for(var g={},t=0;t<this.changedOptions.length;t++)this._constructOptions(this.changedOptions[t].value,this.changedOptions[t].path,g);return g}}]),g}(),cf=function(){function g(t,A){Us(this,g),this.container=t,this.overflowMethod=A||"cap",this.x=0,this.y=0,this.padding=5,this.hidden=!1,this.frame=document.createElement("div"),this.frame.className="vis-tooltip",this.container.appendChild(this.frame)}return Xs(g,[{key:"setPosition",value:function(g,t){this.x=wl(g),this.y=wl(t)}},{key:"setText",value:function(g){if(g instanceof Element){for(;this.frame.firstChild;)this.frame.removeChild(this.frame.firstChild);this.frame.appendChild(g)}else this.frame.innerText=g}},{key:"show",value:function(g){if(void 0===g&&(g=!0),!0===g){var t=this.frame.clientHeight,A=this.frame.clientWidth,e=this.frame.parentNode.clientHeight,C=this.frame.parentNode.clientWidth,I=0,i=0;if("flip"==this.overflowMethod){var o=!1,n=!0;this.y-t<this.padding&&(n=!1),this.x+A>C-this.padding&&(o=!0),I=o?this.x-A:this.x,i=n?this.y-t:this.y}else(i=this.y-t)+t+this.padding>e&&(i=e-t-this.padding),i<this.padding&&(i=this.padding),(I=this.x)+A+this.padding>C&&(I=C-A-this.padding),I<this.padding&&(I=this.padding);this.frame.style.left=I+"px",this.frame.style.top=i+"px",this.frame.style.visibility="visible",this.hidden=!1}else this.hide()}},{key:"hide",value:function(){this.hidden=!0,this.frame.style.left="0",this.frame.style.top="0",this.frame.style.visibility="hidden"}},{key:"destroy",value:function(){this.frame.parentNode.removeChild(this.frame)}}]),g}(),uf=!1,pf="background: #FFeeee; color: #dd0000",ff=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"validate",value:function(t,A,e){uf=!1,hf=A;var C=A;return void 0!==e&&(C=A[e]),g.parse(t,C,[]),uf}},{key:"parse",value:function(t,A,e){for(var C in t)Object.prototype.hasOwnProperty.call(t,C)&&g.check(C,t,A,e)}},{key:"check",value:function(t,A,e,C){if(void 0!==e[t]||void 0!==e.__any__){var I=t,i=!0;void 0===e[t]&&void 0!==e.__any__&&(I="__any__",i="object"===g.getType(A[t]));var o=e[I];i&&void 0!==o.__type__&&(o=o.__type__),g.checkFields(t,A,e,I,o,C)}else g.getSuggestion(t,e,C)}},{key:"checkFields",value:function(t,A,e,C,I,i){var o=function(A){console.error("%c"+A+g.printLocation(i,t),pf)},n=g.getType(A[t]),r=I[n];void 0!==r?"array"===g.getType(r)&&-1===zl(r).call(r,A[t])?(o('Invalid option detected in "'+t+'". Allowed values are:'+g.print(r)+' not "'+A[t]+'". '),uf=!0):"object"===n&&"__any__"!==C&&(i=Qp(i,t),g.parse(A[t],e[C],i)):void 0===I.any&&(o('Invalid type received for "'+t+'". Expected: '+g.print(yd(I))+". Received ["+n+'] "'+A[t]+'"'),uf=!0)}},{key:"getType",value:function(g){var t=$a(g);return"object"===t?null===g?"null":g instanceof Boolean?"boolean":g instanceof Number?"number":g instanceof String?"string":sd(g)?"array":g instanceof Date?"date":void 0!==g.nodeType?"dom":!0===g._isAMomentObject?"moment":"object":"number"===t?"number":"boolean"===t?"boolean":"string"===t?"string":void 0===t?"undefined":t}},{key:"getSuggestion",value:function(t,A,e){var C,I=g.findInOptions(t,A,e,!1),i=g.findInOptions(t,hf,[],!0);C=void 0!==I.indexMatch?" in "+g.printLocation(I.path,t,"")+'Perhaps it was incomplete? Did you mean: "'+I.indexMatch+'"?\n\n':i.distance<=4&&I.distance>i.distance?" in "+g.printLocation(I.path,t,"")+"Perhaps it was misplaced? Matching option found at: "+g.printLocation(i.path,i.closestMatch,""):I.distance<=8?'. Did you mean "'+I.closestMatch+'"?'+g.printLocation(I.path,t):". Did you mean one of these: "+g.print(yd(A))+g.printLocation(e,t),console.error('%cUnknown option detected: "'+t+'"'+C,pf),uf=!0}},{key:"findInOptions",value:function(t,A,e){var C=arguments.length>3&&void 0!==arguments[3]&&arguments[3],I=1e9,i="",o=[],n=t.toLowerCase(),r=void 0;for(var s in A){var a=void 0;if(void 0!==A[s].__type__&&!0===C){var d=g.findInOptions(t,A[s],Qp(e,s));I>d.distance&&(i=d.closestMatch,o=d.path,I=d.distance,r=d.indexMatch)}else{var h;-1!==zl(h=s.toLowerCase()).call(h,n)&&(r=s),I>(a=g.levenshteinDistance(t,s))&&(i=s,o=_p(e),I=a)}}return{closestMatch:i,path:o,distance:I,indexMatch:r}}},{key:"printLocation",value:function(g,t){for(var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"Problem value found at: \n",e="\n\n"+A+"options = {\n",C=0;C<g.length;C++){for(var I=0;I<C+1;I++)e+="  ";e+=g[C]+": {\n"}for(var i=0;i<g.length+1;i++)e+="  ";e+=t+"\n";for(var o=0;o<g.length+1;o++){for(var n=0;n<g.length-o;n++)e+="  ";e+="}\n"}return e+"\n\n"}},{key:"print",value:function(g){return rc(g).replace(/(")|(\[)|(\])|(,"__type__")/g,"").replace(/(,)/g,", ")}},{key:"levenshteinDistance",value:function(g,t){if(0===g.length)return t.length;if(0===t.length)return g.length;var A,e,C=[];for(A=0;A<=t.length;A++)C[A]=[A];for(e=0;e<=g.length;e++)C[0][e]=e;for(A=1;A<=t.length;A++)for(e=1;e<=g.length;e++)t.charAt(A-1)==g.charAt(e-1)?C[A][e]=C[A-1][e-1]:C[A][e]=Math.min(C[A-1][e-1]+1,Math.min(C[A][e-1]+1,C[A-1][e]+1));return C[t.length][g.length]}}]),g}(),vf=Pp,yf=lf,mf=Rp,bf=cf,wf=pf,kf=ff;function xf(g){return Mf=g,function(){var g={};Bf=0,void(zf=Mf.charAt(0)),_f(),"strict"===Zf&&(g.strict=!0,_f());"graph"!==Zf&&"digraph"!==Zf||(g.type=Zf,_f());Sf===Nf&&(g.id=Zf,_f());if("{"!=Zf)throw qf("Angle bracket { expected");if(_f(),Uf(g),"}"!=Zf)throw qf("Angle bracket } expected");if(_f(),""!==Zf)throw qf("End of file expected");return _f(),delete g.node,delete g.edge,delete g.graph,g}()}var Ef={fontsize:"font.size",fontcolor:"font.color",labelfontcolor:"font.color",fontname:"font.face",color:["color.border","color.background"],fillcolor:"color.background",tooltip:"title",labeltooltip:"title"},Of=Vl(Ef);Of.color="color.color",Of.style="dashes";var Tf=0,Df=1,Nf=2,Rf=3,Pf={"{":!0,"}":!0,"[":!0,"]":!0,";":!0,"=":!0,",":!0,"->":!0,"--":!0},Mf="",Bf=0,zf="",Zf="",Sf=Tf;function Ff(){Bf++,zf=Mf.charAt(Bf)}function Gf(){return Mf.charAt(Bf+1)}function Lf(g){var t=g.charCodeAt(0);return t<47?35===t||46===t:t<59?t>47:t<91?t>64:t<96?95===t:t<123&&t>96}function jf(g,t){if(g||(g={}),t)for(var A in t)t.hasOwnProperty(A)&&(g[A]=t[A]);return g}function Vf(g,t,A){for(var e=t.split("."),C=g;e.length;){var I=e.shift();e.length?(C[I]||(C[I]={}),C=C[I]):C[I]=A}}function Yf(g,t){for(var A,e,C=null,I=[g],i=g;i.parent;)I.push(i.parent),i=i.parent;if(i.nodes)for(A=0,e=i.nodes.length;A<e;A++)if(t.id===i.nodes[A].id){C=i.nodes[A];break}for(C||(C={id:t.id},g.node&&(C.attr=jf(C.attr,g.node))),A=I.length-1;A>=0;A--){var o,n=I[A];n.nodes||(n.nodes=[]),-1===zl(o=n.nodes).call(o,C)&&n.nodes.push(C)}t.attr&&(C.attr=jf(C.attr,t.attr))}function Wf(g,t){if(g.edges||(g.edges=[]),g.edges.push(t),g.edge){var A=jf({},g.edge);t.attr=jf(A,t.attr)}}function Qf(g,t,A,e,C){var I={from:t,to:A,type:e};return g.edge&&(I.attr=jf({},g.edge)),I.attr=jf(I.attr||{},C),null!=C&&C.hasOwnProperty("arrows")&&null!=C.arrows&&(I.arrows={to:{enabled:!0,type:C.arrows.type}},C.arrows=null),I}function _f(){for(Sf=Tf,Zf="";" "===zf||"\t"===zf||"\n"===zf||"\r"===zf;)Ff();do{var g=!1;if("#"===zf){for(var t=Bf-1;" "===Mf.charAt(t)||"\t"===Mf.charAt(t);)t--;if("\n"===Mf.charAt(t)||""===Mf.charAt(t)){for(;""!=zf&&"\n"!=zf;)Ff();g=!0}}if("/"===zf&&"/"===Gf()){for(;""!=zf&&"\n"!=zf;)Ff();g=!0}if("/"===zf&&"*"===Gf()){for(;""!=zf;){if("*"===zf&&"/"===Gf()){Ff(),Ff();break}Ff()}g=!0}for(;" "===zf||"\t"===zf||"\n"===zf||"\r"===zf;)Ff()}while(g);if(""!==zf){var A=zf+Gf();if(Pf[A])return Sf=Df,Zf=A,Ff(),void Ff();if(Pf[zf])return Sf=Df,Zf=zf,void Ff();if(Lf(zf)||"-"===zf){for(Zf+=zf,Ff();Lf(zf);)Zf+=zf,Ff();return"false"===Zf?Zf=!1:"true"===Zf?Zf=!0:isNaN(Number(Zf))||(Zf=Number(Zf)),void(Sf=Nf)}if('"'===zf){for(Ff();""!=zf&&('"'!=zf||'"'===zf&&'"'===Gf());)'"'===zf?(Zf+=zf,Ff()):"\\"===zf&&"n"===Gf()?(Zf+="\n",Ff()):Zf+=zf,Ff();if('"'!=zf)throw qf('End of string " expected');return Ff(),void(Sf=Nf)}for(Sf=Rf;""!=zf;)Zf+=zf,Ff();throw new SyntaxError('Syntax error in part "'+$f(Zf,30)+'"')}Sf=Df}function Uf(g){for(;""!==Zf&&"}"!=Zf;)Hf(g),";"===Zf&&_f()}function Hf(g){var t=Kf(g);if(t)Xf(g,t);else{var A=function(g){if("node"===Zf)return _f(),g.node=Jf(),"node";if("edge"===Zf)return _f(),g.edge=Jf(),"edge";if("graph"===Zf)return _f(),g.graph=Jf(),"graph";return null}(g);if(!A){if(Sf!=Nf)throw qf("Identifier expected");var e=Zf;if(_f(),"="===Zf){if(_f(),Sf!=Nf)throw qf("Identifier expected");g[e]=Zf,_f()}else!function(g,t){var A={id:t},e=Jf();e&&(A.attr=e);Yf(g,A),Xf(g,t)}(g,e)}}}function Kf(g){var t=null;if("subgraph"===Zf&&((t={}).type="subgraph",_f(),Sf===Nf&&(t.id=Zf,_f())),"{"===Zf){if(_f(),t||(t={}),t.parent=g,t.node=g.node,t.edge=g.edge,t.graph=g.graph,Uf(t),"}"!=Zf)throw qf("Angle bracket } expected");_f(),delete t.node,delete t.edge,delete t.graph,delete t.parent,g.subgraphs||(g.subgraphs=[]),g.subgraphs.push(t)}return t}function Xf(g,t){for(;"->"===Zf||"--"===Zf;){var A,e=Zf;_f();var C=Kf(g);if(C)A=C;else{if(Sf!=Nf)throw qf("Identifier or subgraph expected");Yf(g,{id:A=Zf}),_f()}Wf(g,Qf(g,t,A,e,Jf())),t=A}}function Jf(){for(var g,t,A=null,e={dashed:!0,solid:!1,dotted:[1,5]},C={dot:"circle",box:"box",crow:"crow",curve:"curve",icurve:"inv_curve",normal:"triangle",inv:"inv_triangle",diamond:"diamond",tee:"bar",vee:"vee"},I=new Array,i=new Array;"["===Zf;){for(_f(),A={};""!==Zf&&"]"!=Zf;){if(Sf!=Nf)throw qf("Attribute name expected");var o=Zf;if(_f(),"="!=Zf)throw qf("Equal sign = expected");if(_f(),Sf!=Nf)throw qf("Attribute value expected");var n=Zf;"style"===o&&(n=e[n]),"arrowhead"===o&&(o="arrows",n={to:{enabled:!0,type:C[n]}}),"arrowtail"===o&&(o="arrows",n={from:{enabled:!0,type:C[n]}}),I.push({attr:A,name:o,value:n}),i.push(o),_f(),","==Zf&&_f()}if("]"!=Zf)throw qf("Bracket ] expected");_f()}if(zh(i).call(i,"dir")){var r={arrows:{}};for(g=0;g<I.length;g++)if("arrows"===I[g].name)if(null!=I[g].value.to)r.arrows.to=g;else{if(null==I[g].value.from)throw qf("Invalid value of arrows");r.arrows.from=g}else"dir"===I[g].name&&(r.dir=g);var s,a,d=I[r.dir].value;if(!zh(i).call(i,"arrows"))if("both"===d)I.push({attr:I[r.dir].attr,name:"arrows",value:{to:{enabled:!0}}}),r.arrows.to=I.length-1,I.push({attr:I[r.dir].attr,name:"arrows",value:{from:{enabled:!0}}}),r.arrows.from=I.length-1;else if("forward"===d)I.push({attr:I[r.dir].attr,name:"arrows",value:{to:{enabled:!0}}}),r.arrows.to=I.length-1;else if("back"===d)I.push({attr:I[r.dir].attr,name:"arrows",value:{from:{enabled:!0}}}),r.arrows.from=I.length-1;else{if("none"!==d)throw qf('Invalid dir type "'+d+'"');I.push({attr:I[r.dir].attr,name:"arrows",value:""}),r.arrows.to=I.length-1}if("both"===d)r.arrows.to&&r.arrows.from?(a=I[r.arrows.to].value.to.type,s=I[r.arrows.from].value.from.type,I[r.arrows.to]={attr:I[r.arrows.to].attr,name:I[r.arrows.to].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}},hh(I).call(I,r.arrows.from,1)):r.arrows.to?(a=I[r.arrows.to].value.to.type,s="arrow",I[r.arrows.to]={attr:I[r.arrows.to].attr,name:I[r.arrows.to].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}):r.arrows.from&&(a="arrow",s=I[r.arrows.from].value.from.type,I[r.arrows.from]={attr:I[r.arrows.from].attr,name:I[r.arrows.from].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}});else if("back"===d)r.arrows.to&&r.arrows.from?(a="",s=I[r.arrows.from].value.from.type,I[r.arrows.from]={attr:I[r.arrows.from].attr,name:I[r.arrows.from].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}):r.arrows.to?(a="",s="arrow",r.arrows.from=r.arrows.to,I[r.arrows.from]={attr:I[r.arrows.from].attr,name:I[r.arrows.from].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}):r.arrows.from&&(a="",s=I[r.arrows.from].value.from.type,I[r.arrows.to]={attr:I[r.arrows.from].attr,name:I[r.arrows.from].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}),I[r.arrows.from]={attr:I[r.arrows.from].attr,name:I[r.arrows.from].name,value:{from:{enabled:!0,type:I[r.arrows.from].value.from.type}}};else if("none"===d){var h;I[h=r.arrows.to?r.arrows.to:r.arrows.from]={attr:I[h].attr,name:I[h].name,value:""}}else{if("forward"!==d)throw qf('Invalid dir type "'+d+'"');r.arrows.to&&r.arrows.from||r.arrows.to?(a=I[r.arrows.to].value.to.type,s="",I[r.arrows.to]={attr:I[r.arrows.to].attr,name:I[r.arrows.to].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}):r.arrows.from&&(a="arrow",s="",r.arrows.to=r.arrows.from,I[r.arrows.to]={attr:I[r.arrows.to].attr,name:I[r.arrows.to].name,value:{to:{enabled:!0,type:a},from:{enabled:!0,type:s}}}),I[r.arrows.to]={attr:I[r.arrows.to].attr,name:I[r.arrows.to].name,value:{to:{enabled:!0,type:I[r.arrows.to].value.to.type}}}}hh(I).call(I,r.dir,1)}if(zh(i).call(i,"penwidth")){var l=[];for(t=I.length,g=0;g<t;g++)"width"!==I[g].name&&("penwidth"===I[g].name&&(I[g].name="width"),l.push(I[g]));I=l}for(t=I.length,g=0;g<t;g++)Vf(I[g].attr,I[g].name,I[g].value);return A}function qf(g){return new SyntaxError(g+', got "'+$f(Zf,30)+'" (char '+Bf+")")}function $f(g,t){return g.length<=t?g:g.substr(0,27)+"..."}function gv(g,t,A){for(var e=t.split("."),C=e.pop(),I=g,i=0;i<e.length;i++){var o=e[i];o in I||(I[o]={}),I=I[o]}return I[C]=A,g}function tv(g,t){var A={};for(var e in g)if(g.hasOwnProperty(e)){var C=t[e];sd(C)?Sd(C).call(C,(function(t){gv(A,t,g[e])})):gv(A,"string"==typeof C?C:e,g[e])}return A}function Av(g){var t,A=xf(g),e={nodes:[],edges:[],options:{}};A.nodes&&Sd(t=A.nodes).call(t,(function(g){var t={id:g.id,label:String(g.label||g.id)};jf(t,tv(g.attr,Ef)),t.image&&(t.shape="image"),e.nodes.push(t)}));if(A.edges){var C,I=function(g){var t={from:g.from,to:g.to};return jf(t,tv(g.attr,Of)),null==t.arrows&&"->"===g.type&&(t.arrows="to"),t};Sd(C=A.edges).call(C,(function(g){var t,A,C,i,o,n,r;(t=g.from instanceof Object?g.from.nodes:{id:g.from},A=g.to instanceof Object?g.to.nodes:{id:g.to},g.from instanceof Object&&g.from.edges)&&Sd(C=g.from.edges).call(C,(function(g){var t=I(g);e.edges.push(t)}));(o=A,n=function(t,A){var C=Qf(e,t.id,A.id,g.type,g.attr),i=I(C);e.edges.push(i)},sd(i=t)?Sd(i).call(i,(function(g){sd(o)?Sd(o).call(o,(function(t){n(g,t)})):n(g,o)})):sd(o)?Sd(o).call(o,(function(g){n(i,g)})):n(i,o),g.to instanceof Object&&g.to.edges)&&Sd(r=g.to.edges).call(r,(function(g){var t=I(g);e.edges.push(t)}))}))}return A.attr&&(e.options=A.attr),e}var ev=Object.freeze({__proto__:null,parseDOT:xf,DOTToGraph:Av});function Cv(g,t){var A,e={edges:{inheritColor:!1},nodes:{fixed:!1,parseColor:!1}};null!=t&&(null!=t.fixed&&(e.nodes.fixed=t.fixed),null!=t.parseColor&&(e.nodes.parseColor=t.parseColor),null!=t.inheritColor&&(e.edges.inheritColor=t.inheritColor));var C=g.edges,I=pd(C).call(C,(function(g){var t={from:g.source,id:g.id,to:g.target};return null!=g.attributes&&(t.attributes=g.attributes),null!=g.label&&(t.label=g.label),null!=g.attributes&&null!=g.attributes.title&&(t.title=g.attributes.title),"Directed"===g.type&&(t.arrows="to"),g.color&&!1===e.edges.inheritColor&&(t.color=g.color),t}));return{nodes:pd(A=g.nodes).call(A,(function(g){var t={id:g.id,fixed:e.nodes.fixed&&null!=g.x&&null!=g.y};return null!=g.attributes&&(t.attributes=g.attributes),null!=g.label&&(t.label=g.label),null!=g.size&&(t.size=g.size),null!=g.attributes&&null!=g.attributes.title&&(t.title=g.attributes.title),null!=g.title&&(t.title=g.title),null!=g.x&&(t.x=g.x),null!=g.y&&(t.y=g.y),null!=g.color&&(!0===e.nodes.parseColor?t.color=g.color:t.color={background:g.color,border:g.color,highlight:{background:g.color,border:g.color},hover:{background:g.color,border:g.color}}),t})),edges:I}}var Iv=Object.freeze({__proto__:null,parseGephi:Cv}),iv=Object.freeze({__proto__:null,en:{addDescription:"Click in an empty space to place a new node.",addEdge:"Add Edge",addNode:"Add Node",back:"Back",close:"Close",createEdgeError:"Cannot link edges to a cluster.",del:"Delete selected",deleteClusterError:"Clusters cannot be deleted.",edgeDescription:"Click on a node and drag the edge to another node to connect them.",edit:"Edit",editClusterError:"Clusters cannot be edited.",editEdge:"Edit Edge",editEdgeDescription:"Click on the control points and drag them to a node to connect to it.",editNode:"Edit Node"},de:{addDescription:"Klicke auf eine freie Stelle, um einen neuen Knoten zu plazieren.",addEdge:"Kante hinzufügen",addNode:"Knoten hinzufügen",back:"Zurück",close:"Schließen",createEdgeError:"Es ist nicht möglich, Kanten mit Clustern zu verbinden.",del:"Lösche Auswahl",deleteClusterError:"Cluster können nicht gelöscht werden.",edgeDescription:"Klicke auf einen Knoten und ziehe die Kante zu einem anderen Knoten, um diese zu verbinden.",edit:"Editieren",editClusterError:"Cluster können nicht editiert werden.",editEdge:"Kante editieren",editEdgeDescription:"Klicke auf die Verbindungspunkte und ziehe diese auf einen Knoten, um sie zu verbinden.",editNode:"Knoten editieren"},es:{addDescription:"Haga clic en un lugar vacío para colocar un nuevo nodo.",addEdge:"Añadir arista",addNode:"Añadir nodo",back:"Atrás",close:"Cerrar",createEdgeError:"No se puede conectar una arista a un grupo.",del:"Eliminar selección",deleteClusterError:"No es posible eliminar grupos.",edgeDescription:"Haga clic en un nodo y arrastre la arista hacia otro nodo para conectarlos.",edit:"Editar",editClusterError:"No es posible editar grupos.",editEdge:"Editar arista",editEdgeDescription:"Haga clic en un punto de control y arrastrelo a un nodo para conectarlo.",editNode:"Editar nodo"},it:{addDescription:"Clicca per aggiungere un nuovo nodo",addEdge:"Aggiungi un vertice",addNode:"Aggiungi un nodo",back:"Indietro",close:"Chiudere",createEdgeError:"Non si possono collegare vertici ad un cluster",del:"Cancella la selezione",deleteClusterError:"I cluster non possono essere cancellati",edgeDescription:"Clicca su un nodo e trascinalo ad un altro nodo per connetterli.",edit:"Modifica",editClusterError:"I clusters non possono essere modificati.",editEdge:"Modifica il vertice",editEdgeDescription:"Clicca sui Punti di controllo e trascinali ad un nodo per connetterli.",editNode:"Modifica il nodo"},nl:{addDescription:"Klik op een leeg gebied om een nieuwe node te maken.",addEdge:"Link toevoegen",addNode:"Node toevoegen",back:"Terug",close:"Sluiten",createEdgeError:"Kan geen link maken naar een cluster.",del:"Selectie verwijderen",deleteClusterError:"Clusters kunnen niet worden verwijderd.",edgeDescription:"Klik op een node en sleep de link naar een andere node om ze te verbinden.",edit:"Wijzigen",editClusterError:"Clusters kunnen niet worden aangepast.",editEdge:"Link wijzigen",editEdgeDescription:"Klik op de verbindingspunten en sleep ze naar een node om daarmee te verbinden.",editNode:"Node wijzigen"},pt:{addDescription:"Clique em um espaço em branco para adicionar um novo nó",addEdge:"Adicionar aresta",addNode:"Adicionar nó",back:"Voltar",close:"Fechar",createEdgeError:"Não foi possível linkar arestas a um cluster.",del:"Remover selecionado",deleteClusterError:"Clusters não puderam ser removidos.",edgeDescription:"Clique em um nó e arraste a aresta até outro nó para conectá-los",edit:"Editar",editClusterError:"Clusters não puderam ser editados.",editEdge:"Editar aresta",editEdgeDescription:"Clique nos pontos de controle e os arraste para um nó para conectá-los",editNode:"Editar nó"},ru:{addDescription:"Кликните в свободное место, чтобы добавить новый узел.",addEdge:"Добавить ребро",addNode:"Добавить узел",back:"Назад",close:"Закрывать",createEdgeError:"Невозможно соединить ребра в кластер.",del:"Удалить выбранное",deleteClusterError:"Кластеры не могут быть удалены",edgeDescription:"Кликните на узел и протяните ребро к другому узлу, чтобы соединить их.",edit:"Редактировать",editClusterError:"Кластеры недоступны для редактирования.",editEdge:"Редактировать ребро",editEdgeDescription:"Кликните на контрольные точки и перетащите их в узел, чтобы подключиться к нему.",editNode:"Редактировать узел"},cn:{addDescription:"单击空白处放置新节点。",addEdge:"添加连接线",addNode:"添加节点",back:"返回",close:"關閉",createEdgeError:"无法将连接线连接到群集。",del:"删除选定",deleteClusterError:"无法删除群集。",edgeDescription:"单击某个节点并将该连接线拖动到另一个节点以连接它们。",edit:"编辑",editClusterError:"无法编辑群集。",editEdge:"编辑连接线",editEdgeDescription:"单击控制节点并将它们拖到节点上连接。",editNode:"编辑节点"},uk:{addDescription:"Kлікніть на вільне місце, щоб додати новий вузол.",addEdge:"Додати край",addNode:"Додати вузол",back:"Назад",close:"Закрити",createEdgeError:"Не можливо об'єднати краї в групу.",del:"Видалити обране",deleteClusterError:"Групи не можуть бути видалені.",edgeDescription:"Клікніть на вузол і перетягніть край до іншого вузла, щоб їх з'єднати.",edit:"Редагувати",editClusterError:"Групи недоступні для редагування.",editEdge:"Редагувати край",editEdgeDescription:"Клікніть на контрольні точки і перетягніть їх у вузол, щоб підключитися до нього.",editNode:"Редагувати вузол"},fr:{addDescription:"Cliquez dans un endroit vide pour placer un nœud.",addEdge:"Ajouter un lien",addNode:"Ajouter un nœud",back:"Retour",close:"Fermer",createEdgeError:"Impossible de créer un lien vers un cluster.",del:"Effacer la sélection",deleteClusterError:"Les clusters ne peuvent pas être effacés.",edgeDescription:"Cliquez sur un nœud et glissez le lien vers un autre nœud pour les connecter.",edit:"Éditer",editClusterError:"Les clusters ne peuvent pas être édités.",editEdge:"Éditer le lien",editEdgeDescription:"Cliquez sur les points de contrôle et glissez-les pour connecter un nœud.",editNode:"Éditer le nœud"},cs:{addDescription:"Kluknutím do prázdného prostoru můžete přidat nový vrchol.",addEdge:"Přidat hranu",addNode:"Přidat vrchol",back:"Zpět",close:"Zavřít",createEdgeError:"Nelze připojit hranu ke shluku.",del:"Smazat výběr",deleteClusterError:"Nelze mazat shluky.",edgeDescription:"Přetažením z jednoho vrcholu do druhého můžete spojit tyto vrcholy novou hranou.",edit:"Upravit",editClusterError:"Nelze upravovat shluky.",editEdge:"Upravit hranu",editEdgeDescription:"Přetažením kontrolního vrcholu hrany ji můžete připojit k jinému vrcholu.",editNode:"Upravit vrchol"}});var ov=function(){function g(){Us(this,g),this.NUM_ITERATIONS=4,this.image=new Image,this.canvas=document.createElement("canvas")}return Xs(g,[{key:"init",value:function(){if(!this.initialized()){this.src=this.image.src;var g=this.image.width,t=this.image.height;this.width=g,this.height=t;var A=Math.floor(t/2),e=Math.floor(t/4),C=Math.floor(t/8),I=Math.floor(t/16),i=Math.floor(g/2),o=Math.floor(g/4),n=Math.floor(g/8),r=Math.floor(g/16);this.canvas.width=3*o,this.canvas.height=A,this.coordinates=[[0,0,i,A],[i,0,o,e],[i,e,n,C],[5*n,e,r,I]],this._fillMipMap()}}},{key:"initialized",value:function(){return void 0!==this.coordinates}},{key:"_fillMipMap",value:function(){var g=this.canvas.getContext("2d"),t=this.coordinates[0];g.drawImage(this.image,t[0],t[1],t[2],t[3]);for(var A=1;A<this.NUM_ITERATIONS;A++){var e=this.coordinates[A-1],C=this.coordinates[A];g.drawImage(this.canvas,e[0],e[1],e[2],e[3],C[0],C[1],C[2],C[3])}}},{key:"drawImageAtPosition",value:function(g,t,A,e,C,I){if(this.initialized())if(t>2){t*=.5;for(var i=0;t>2&&i<this.NUM_ITERATIONS;)t*=.5,i+=1;i>=this.NUM_ITERATIONS&&(i=this.NUM_ITERATIONS-1);var o=this.coordinates[i];g.drawImage(this.canvas,o[0],o[1],o[2],o[3],A,e,C,I)}else g.drawImage(this.image,A,e,C,I)}}]),g}(),nv=function(){function g(t){Us(this,g),this.images={},this.imageBroken={},this.callback=t}return Xs(g,[{key:"_tryloadBrokenUrl",value:function(g,t,A){void 0!==g&&void 0!==A&&(void 0!==t?(A.image.onerror=function(){console.error("Could not load brokenImage:",t)},A.image.src=t):console.warn("No broken url image defined"))}},{key:"_redrawWithImage",value:function(g){this.callback&&this.callback(g)}},{key:"load",value:function(g,t){var A=this,e=this.images[g];if(e)return e;var C=new ov;return this.images[g]=C,C.image.onload=function(){A._fixImageCoordinates(C.image),C.init(),A._redrawWithImage(C)},C.image.onerror=function(){console.error("Could not load image:",g),A._tryloadBrokenUrl(g,t,C)},C.image.src=g,C}},{key:"_fixImageCoordinates",value:function(g){0===g.width&&(document.body.appendChild(g),g.width=g.offsetWidth,g.height=g.offsetHeight,document.body.removeChild(g))}}]),g}(),rv={exports:{}},sv=C((function(){if("function"==typeof ArrayBuffer){var g=new ArrayBuffer(8);Object.isExtensible(g)&&Object.defineProperty(g,"a",{value:8})}})),av=C,dv=_,hv=M,lv=sv,cv=Object.isExtensible,uv=av((function(){cv(1)}))||lv?function(g){return!!dv(g)&&((!lv||"ArrayBuffer"!=hv(g))&&(!cv||cv(g)))}:cv,pv=!C((function(){return Object.isExtensible(Object.preventExtensions({}))})),fv=bA,vv=u,yv=GA,mv=_,bv=Vg,wv=Wt.f,kv=In,xv=rn,Ev=uv,Ov=pv,Tv=!1,Dv=Ug("meta"),Nv=0,Rv=function(g){wv(g,Dv,{value:{objectID:"O"+Nv++,weakData:{}}})},Pv=rv.exports={enable:function(){Pv.enable=function(){},Tv=!0;var g=kv.f,t=vv([].splice),A={};A[Dv]=1,g(A).length&&(kv.f=function(A){for(var e=g(A),C=0,I=e.length;C<I;C++)if(e[C]===Dv){t(e,C,1);break}return e},fv({target:"Object",stat:!0,forced:!0},{getOwnPropertyNames:xv.f}))},fastKey:function(g,t){if(!mv(g))return"symbol"==typeof g?g:("string"==typeof g?"S":"P")+g;if(!bv(g,Dv)){if(!Ev(g))return"F";if(!t)return"E";Rv(g)}return g[Dv].objectID},getWeakData:function(g,t){if(!bv(g,Dv)){if(!Ev(g))return!0;if(!t)return!1;Rv(g)}return g[Dv].weakData},onFreeze:function(g){return Ov&&Tv&&Ev(g)&&!bv(g,Dv)&&Rv(g),g}};yv[Dv]=!0;var Mv=Yt,Bv=b,zv=Xt,Zv=vg,Sv=Vi,Fv=MA,Gv=$,Lv=vo,jv=ao,Vv=Zi,Yv=e.TypeError,Wv=function(g,t){this.stopped=g,this.result=t},Qv=Wv.prototype,_v=function(g,t,A){var e,C,I,i,o,n,r,s=A&&A.that,a=!(!A||!A.AS_ENTRIES),d=!(!A||!A.IS_ITERATOR),h=!(!A||!A.INTERRUPTED),l=Mv(t,s),c=function(g){return e&&Vv(e,"normal",g),new Wv(!0,g)},u=function(g){return a?(zv(g),h?l(g[0],g[1],c):l(g[0],g[1])):h?l(g,c):l(g)};if(d)e=g;else{if(!(C=jv(g)))throw Yv(Zv(g)+" is not iterable");if(Sv(C)){for(I=0,i=Fv(g);i>I;I++)if((o=u(g[I]))&&Gv(Qv,o))return o;return new Wv(!1)}e=Lv(g,C)}for(n=e.next;!(r=Bv(n,e)).done;){try{o=u(r.value)}catch(g){Vv(e,"throw",g)}if("object"==typeof o&&o&&Gv(Qv,o))return o}return new Wv(!1)},Uv=$,Hv=e.TypeError,Kv=function(g,t){if(Uv(t,g))return g;throw Hv("Incorrect invocation")},Xv=bA,Jv=e,qv=rv.exports,$v=C,gy=sA,ty=_v,Ay=Kv,ey=p,Cy=_,Iy=KI,iy=Wt.f,oy=Yn.forEach,ny=v,ry=YC.set,sy=YC.getterFor,ay=function(g,t,A){var e,C=-1!==g.indexOf("Map"),I=-1!==g.indexOf("Weak"),i=C?"set":"add",o=Jv[g],n=o&&o.prototype,r={};if(ny&&ey(o)&&(I||n.forEach&&!$v((function(){(new o).entries().next()})))){var s=(e=t((function(t,A){ry(Ay(t,s),{type:g,collection:new o}),null!=A&&ty(A,t[i],{that:t,AS_ENTRIES:C})}))).prototype,a=sy(g);oy(["add","clear","delete","forEach","get","has","set","keys","values","entries"],(function(g){var t="add"==g||"set"==g;!(g in n)||I&&"clear"==g||gy(s,g,(function(A,e){var C=a(this).collection;if(!t&&I&&!Cy(A))return"get"==g&&void 0;var i=C[g](0===A?0:A,e);return t?this:i}))})),I||iy(s,"size",{configurable:!0,get:function(){return a(this).collection.size}})}else e=A.getConstructor(t,g,C,i),qv.enable();return Iy(e,g,!1,!0),r[g]=e,Xv({global:!0,forced:!0},r),I||A.setStrong(e,g,C),e},dy=RI,hy=function(g,t,A){for(var e in t)A&&A.unsafe&&g[e]?g[e]=t[e]:dy(g,e,t[e],A);return g},ly=q,cy=Wt,uy=v,py=Ct("species"),fy=Wt.f,vy=vI,yy=hy,my=Yt,by=Kv,wy=_v,ky=xi,xy=function(g){var t=ly(g),A=cy.f;uy&&t&&!t[py]&&A(t,py,{configurable:!0,get:function(){return this}})},Ey=v,Oy=rv.exports.fastKey,Ty=YC.set,Dy=YC.getterFor,Ny={getConstructor:function(g,t,A,e){var C=g((function(g,C){by(g,I),Ty(g,{type:t,index:vy(null),first:void 0,last:void 0,size:0}),Ey||(g.size=0),null!=C&&wy(C,g[e],{that:g,AS_ENTRIES:A})})),I=C.prototype,i=Dy(t),o=function(g,t,A){var e,C,I=i(g),o=n(g,t);return o?o.value=A:(I.last=o={index:C=Oy(t,!0),key:t,value:A,previous:e=I.last,next:void 0,removed:!1},I.first||(I.first=o),e&&(e.next=o),Ey?I.size++:g.size++,"F"!==C&&(I.index[C]=o)),g},n=function(g,t){var A,e=i(g),C=Oy(t);if("F"!==C)return e.index[C];for(A=e.first;A;A=A.next)if(A.key==t)return A};return yy(I,{clear:function(){for(var g=i(this),t=g.index,A=g.first;A;)A.removed=!0,A.previous&&(A.previous=A.previous.next=void 0),delete t[A.index],A=A.next;g.first=g.last=void 0,Ey?g.size=0:this.size=0},delete:function(g){var t=this,A=i(t),e=n(t,g);if(e){var C=e.next,I=e.previous;delete A.index[e.index],e.removed=!0,I&&(I.next=C),C&&(C.previous=I),A.first==e&&(A.first=C),A.last==e&&(A.last=I),Ey?A.size--:t.size--}return!!e},forEach:function(g){for(var t,A=i(this),e=my(g,arguments.length>1?arguments[1]:void 0);t=t?t.next:A.first;)for(e(t.value,t.key,this);t&&t.removed;)t=t.previous},has:function(g){return!!n(this,g)}}),yy(I,A?{get:function(g){var t=n(this,g);return t&&t.value},set:function(g,t){return o(this,0===g?0:g,t)}}:{add:function(g){return o(this,g=0===g?0:g,g)}}),Ey&&fy(I,"size",{get:function(){return i(this).size}}),C},setStrong:function(g,t,A){var e=t+" Iterator",C=Dy(t),I=Dy(e);ky(g,t,(function(g,t){Ty(this,{type:e,target:g,state:C(g),kind:t,last:void 0})}),(function(){for(var g=I(this),t=g.kind,A=g.last;A&&A.removed;)A=A.previous;return g.target&&(g.last=A=A?A.next:g.state.first)?"keys"==t?{value:A.key,done:!1}:"values"==t?{value:A.value,done:!1}:{value:[A.key,A.value],done:!1}:(g.target=void 0,{value:void 0,done:!0})}),A?"entries":"values",!A,!0),xy(t)}};ay("Map",(function(g){return function(){return g(this,arguments.length?arguments[0]:void 0)}}),Ny);var Ry=U.Map,Py=function(){function g(){Us(this,g),this.clear(),this._defaultIndex=0,this._groupIndex=0,this._defaultGroups=[{border:"#2B7CE9",background:"#97C2FC",highlight:{border:"#2B7CE9",background:"#D2E5FF"},hover:{border:"#2B7CE9",background:"#D2E5FF"}},{border:"#FFA500",background:"#FFFF00",highlight:{border:"#FFA500",background:"#FFFFA3"},hover:{border:"#FFA500",background:"#FFFFA3"}},{border:"#FA0A10",background:"#FB7E81",highlight:{border:"#FA0A10",background:"#FFAFB1"},hover:{border:"#FA0A10",background:"#FFAFB1"}},{border:"#41A906",background:"#7BE141",highlight:{border:"#41A906",background:"#A1EC76"},hover:{border:"#41A906",background:"#A1EC76"}},{border:"#E129F0",background:"#EB7DF4",highlight:{border:"#E129F0",background:"#F0B3F5"},hover:{border:"#E129F0",background:"#F0B3F5"}},{border:"#7C29F0",background:"#AD85E4",highlight:{border:"#7C29F0",background:"#D3BDF0"},hover:{border:"#7C29F0",background:"#D3BDF0"}},{border:"#C37F00",background:"#FFA807",highlight:{border:"#C37F00",background:"#FFCA66"},hover:{border:"#C37F00",background:"#FFCA66"}},{border:"#4220FB",background:"#6E6EFD",highlight:{border:"#4220FB",background:"#9B9BFD"},hover:{border:"#4220FB",background:"#9B9BFD"}},{border:"#FD5A77",background:"#FFC0CB",highlight:{border:"#FD5A77",background:"#FFD1D9"},hover:{border:"#FD5A77",background:"#FFD1D9"}},{border:"#4AD63A",background:"#C2FABC",highlight:{border:"#4AD63A",background:"#E6FFE3"},hover:{border:"#4AD63A",background:"#E6FFE3"}},{border:"#990000",background:"#EE0000",highlight:{border:"#BB0000",background:"#FF3333"},hover:{border:"#BB0000",background:"#FF3333"}},{border:"#FF6000",background:"#FF6000",highlight:{border:"#FF6000",background:"#FF6000"},hover:{border:"#FF6000",background:"#FF6000"}},{border:"#97C2FC",background:"#2B7CE9",highlight:{border:"#D2E5FF",background:"#2B7CE9"},hover:{border:"#D2E5FF",background:"#2B7CE9"}},{border:"#399605",background:"#255C03",highlight:{border:"#399605",background:"#255C03"},hover:{border:"#399605",background:"#255C03"}},{border:"#B70054",background:"#FF007E",highlight:{border:"#B70054",background:"#FF007E"},hover:{border:"#B70054",background:"#FF007E"}},{border:"#AD85E4",background:"#7C29F0",highlight:{border:"#D3BDF0",background:"#7C29F0"},hover:{border:"#D3BDF0",background:"#7C29F0"}},{border:"#4557FA",background:"#000EA1",highlight:{border:"#6E6EFD",background:"#000EA1"},hover:{border:"#6E6EFD",background:"#000EA1"}},{border:"#FFC0CB",background:"#FD5A77",highlight:{border:"#FFD1D9",background:"#FD5A77"},hover:{border:"#FFD1D9",background:"#FD5A77"}},{border:"#C2FABC",background:"#74D66A",highlight:{border:"#E6FFE3",background:"#74D66A"},hover:{border:"#E6FFE3",background:"#74D66A"}},{border:"#EE0000",background:"#990000",highlight:{border:"#FF3333",background:"#BB0000"},hover:{border:"#FF3333",background:"#BB0000"}}],this.options={},this.defaultOptions={useDefaultGroups:!0},ae(this.options,this.defaultOptions)}return Xs(g,[{key:"setOptions",value:function(g){var t=["useDefaultGroups"];if(void 0!==g)for(var A in g)if(Object.prototype.hasOwnProperty.call(g,A)&&-1===zl(t).call(t,A)){var e=g[A];this.add(A,e)}}},{key:"clear",value:function(){this._groups=new Ry,this._groupNames=[]}},{key:"get",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],A=this._groups.get(g);if(void 0===A&&t)if(!1===this.options.useDefaultGroups&&this._groupNames.length>0){var e=this._groupIndex%this._groupNames.length;++this._groupIndex,(A={}).color=this._groups.get(this._groupNames[e]),this._groups.set(g,A)}else{var C=this._defaultIndex%this._defaultGroups.length;this._defaultIndex++,(A={}).color=this._defaultGroups[C],this._groups.set(g,A)}return A}},{key:"add",value:function(g,t){return this._groups.has(g)||this._groupNames.push(g),this._groups.set(g,t),t}}]),g}();bA({target:"Number",stat:!0},{isNaN:function(g){return g!=g}});var My=U.Number.isNaN,By=e.isFinite,zy=Number.isFinite||function(g){return"number"==typeof g&&By(g)};bA({target:"Number",stat:!0},{isFinite:zy});var Zy=U.Number.isFinite,Sy=Yn.some;bA({target:"Array",proto:!0,forced:!Od("some")},{some:function(g){return Sy(this,g,arguments.length>1?arguments[1]:void 0)}});var Fy=Oe("Array").some,Gy=$,Ly=Fy,jy=Array.prototype,Vy=function(g){var t=g.some;return g===jy||Gy(jy,g)&&t===jy.some?Ly:t},Yy=Vy,Wy=Ao,Qy=vg,_y=e.TypeError,Uy=bA,Hy=s,Ky=ke,Xy=function(g){if(Wy(g))return g;throw _y(Qy(g)+" is not a constructor")},Jy=Xt,qy=_,$y=vI,gm=C,tm=q("Reflect","construct"),Am=Object.prototype,em=[].push,Cm=gm((function(){function g(){}return!(tm((function(){}),[],g)instanceof g)})),Im=!gm((function(){tm((function(){}))})),im=Cm||Im;Uy({target:"Reflect",stat:!0,forced:im,sham:im},{construct:function(g,t){Xy(g),Jy(t);var A=arguments.length<3?g:Xy(arguments[2]);if(Im&&!Cm)return tm(g,t,A);if(g==A){switch(t.length){case 0:return new g;case 1:return new g(t[0]);case 2:return new g(t[0],t[1]);case 3:return new g(t[0],t[1],t[2]);case 4:return new g(t[0],t[1],t[2],t[3])}var e=[null];return Hy(em,e,t),new(Hy(Ky,g,e))}var C=A.prototype,I=$y(qy(C)?C:Am),i=Hy(g,I,t);return qy(i)?i:I}});var om=U.Reflect.construct;function nm(g){if(void 0===g)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return g}var rm=jl;bA({target:"Object",stat:!0},{setPrototypeOf:si});var sm=U.Object.setPrototypeOf;function am(g,t){return am=sm||function(g,t){return g.__proto__=t,g},am(g,t)}function dm(g,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function");g.prototype=rm(t&&t.prototype,{constructor:{value:g,writable:!0,configurable:!0}}),Hs(g,"prototype",{writable:!1}),t&&am(g,t)}function hm(g,t){if(t&&("object"===$a(t)||"function"==typeof t))return t;if(void 0!==t)throw new TypeError("Derived constructors may only return object or undefined");return nm(g)}var lm=Gh;function cm(g){return cm=sm?lm:function(g){return g.__proto__||lm(g)},cm(g)}var um={exports:{}};!function(g){var t=function(g){var t,A=Object.prototype,e=A.hasOwnProperty,C="function"==typeof Symbol?Symbol:{},I=C.iterator||"@@iterator",i=C.asyncIterator||"@@asyncIterator",o=C.toStringTag||"@@toStringTag";function n(g,t,A){return Object.defineProperty(g,t,{value:A,enumerable:!0,configurable:!0,writable:!0}),g[t]}try{n({},"")}catch(g){n=function(g,t,A){return g[t]=A}}function r(g,t,A,e){var C=t&&t.prototype instanceof u?t:u,I=Object.create(C.prototype),i=new T(e||[]);return I._invoke=function(g,t,A){var e=a;return function(C,I){if(e===h)throw new Error("Generator is already running");if(e===l){if("throw"===C)throw I;return N()}for(A.method=C,A.arg=I;;){var i=A.delegate;if(i){var o=x(i,A);if(o){if(o===c)continue;return o}}if("next"===A.method)A.sent=A._sent=A.arg;else if("throw"===A.method){if(e===a)throw e=l,A.arg;A.dispatchException(A.arg)}else"return"===A.method&&A.abrupt("return",A.arg);e=h;var n=s(g,t,A);if("normal"===n.type){if(e=A.done?l:d,n.arg===c)continue;return{value:n.arg,done:A.done}}"throw"===n.type&&(e=l,A.method="throw",A.arg=n.arg)}}}(g,A,i),I}function s(g,t,A){try{return{type:"normal",arg:g.call(t,A)}}catch(g){return{type:"throw",arg:g}}}g.wrap=r;var a="suspendedStart",d="suspendedYield",h="executing",l="completed",c={};function u(){}function p(){}function f(){}var v={};n(v,I,(function(){return this}));var y=Object.getPrototypeOf,m=y&&y(y(D([])));m&&m!==A&&e.call(m,I)&&(v=m);var b=f.prototype=u.prototype=Object.create(v);function w(g){["next","throw","return"].forEach((function(t){n(g,t,(function(g){return this._invoke(t,g)}))}))}function k(g,t){function A(C,I,i,o){var n=s(g[C],g,I);if("throw"!==n.type){var r=n.arg,a=r.value;return a&&"object"==typeof a&&e.call(a,"__await")?t.resolve(a.__await).then((function(g){A("next",g,i,o)}),(function(g){A("throw",g,i,o)})):t.resolve(a).then((function(g){r.value=g,i(r)}),(function(g){return A("throw",g,i,o)}))}o(n.arg)}var C;this._invoke=function(g,e){function I(){return new t((function(t,C){A(g,e,t,C)}))}return C=C?C.then(I,I):I()}}function x(g,A){var e=g.iterator[A.method];if(e===t){if(A.delegate=null,"throw"===A.method){if(g.iterator.return&&(A.method="return",A.arg=t,x(g,A),"throw"===A.method))return c;A.method="throw",A.arg=new TypeError("The iterator does not provide a 'throw' method")}return c}var C=s(e,g.iterator,A.arg);if("throw"===C.type)return A.method="throw",A.arg=C.arg,A.delegate=null,c;var I=C.arg;return I?I.done?(A[g.resultName]=I.value,A.next=g.nextLoc,"return"!==A.method&&(A.method="next",A.arg=t),A.delegate=null,c):I:(A.method="throw",A.arg=new TypeError("iterator result is not an object"),A.delegate=null,c)}function E(g){var t={tryLoc:g[0]};1 in g&&(t.catchLoc=g[1]),2 in g&&(t.finallyLoc=g[2],t.afterLoc=g[3]),this.tryEntries.push(t)}function O(g){var t=g.completion||{};t.type="normal",delete t.arg,g.completion=t}function T(g){this.tryEntries=[{tryLoc:"root"}],g.forEach(E,this),this.reset(!0)}function D(g){if(g){var A=g[I];if(A)return A.call(g);if("function"==typeof g.next)return g;if(!isNaN(g.length)){var C=-1,i=function A(){for(;++C<g.length;)if(e.call(g,C))return A.value=g[C],A.done=!1,A;return A.value=t,A.done=!0,A};return i.next=i}}return{next:N}}function N(){return{value:t,done:!0}}return p.prototype=f,n(b,"constructor",f),n(f,"constructor",p),p.displayName=n(f,o,"GeneratorFunction"),g.isGeneratorFunction=function(g){var t="function"==typeof g&&g.constructor;return!!t&&(t===p||"GeneratorFunction"===(t.displayName||t.name))},g.mark=function(g){return Object.setPrototypeOf?Object.setPrototypeOf(g,f):(g.__proto__=f,n(g,o,"GeneratorFunction")),g.prototype=Object.create(b),g},g.awrap=function(g){return{__await:g}},w(k.prototype),n(k.prototype,i,(function(){return this})),g.AsyncIterator=k,g.async=function(t,A,e,C,I){void 0===I&&(I=Promise);var i=new k(r(t,A,e,C),I);return g.isGeneratorFunction(A)?i:i.next().then((function(g){return g.done?g.value:i.next()}))},w(b),n(b,o,"Generator"),n(b,I,(function(){return this})),n(b,"toString",(function(){return"[object Generator]"})),g.keys=function(g){var t=[];for(var A in g)t.push(A);return t.reverse(),function A(){for(;t.length;){var e=t.pop();if(e in g)return A.value=e,A.done=!1,A}return A.done=!0,A}},g.values=D,T.prototype={constructor:T,reset:function(g){if(this.prev=0,this.next=0,this.sent=this._sent=t,this.done=!1,this.delegate=null,this.method="next",this.arg=t,this.tryEntries.forEach(O),!g)for(var A in this)"t"===A.charAt(0)&&e.call(this,A)&&!isNaN(+A.slice(1))&&(this[A]=t)},stop:function(){this.done=!0;var g=this.tryEntries[0].completion;if("throw"===g.type)throw g.arg;return this.rval},dispatchException:function(g){if(this.done)throw g;var A=this;function C(e,C){return o.type="throw",o.arg=g,A.next=e,C&&(A.method="next",A.arg=t),!!C}for(var I=this.tryEntries.length-1;I>=0;--I){var i=this.tryEntries[I],o=i.completion;if("root"===i.tryLoc)return C("end");if(i.tryLoc<=this.prev){var n=e.call(i,"catchLoc"),r=e.call(i,"finallyLoc");if(n&&r){if(this.prev<i.catchLoc)return C(i.catchLoc,!0);if(this.prev<i.finallyLoc)return C(i.finallyLoc)}else if(n){if(this.prev<i.catchLoc)return C(i.catchLoc,!0)}else{if(!r)throw new Error("try statement without catch or finally");if(this.prev<i.finallyLoc)return C(i.finallyLoc)}}}},abrupt:function(g,t){for(var A=this.tryEntries.length-1;A>=0;--A){var C=this.tryEntries[A];if(C.tryLoc<=this.prev&&e.call(C,"finallyLoc")&&this.prev<C.finallyLoc){var I=C;break}}I&&("break"===g||"continue"===g)&&I.tryLoc<=t&&t<=I.finallyLoc&&(I=null);var i=I?I.completion:{};return i.type=g,i.arg=t,I?(this.method="next",this.next=I.finallyLoc,c):this.complete(i)},complete:function(g,t){if("throw"===g.type)throw g.arg;return"break"===g.type||"continue"===g.type?this.next=g.arg:"return"===g.type?(this.rval=this.arg=g.arg,this.method="return",this.next="end"):"normal"===g.type&&t&&(this.next=t),c},finish:function(g){for(var t=this.tryEntries.length-1;t>=0;--t){var A=this.tryEntries[t];if(A.finallyLoc===g)return this.complete(A.completion,A.afterLoc),O(A),c}},catch:function(g){for(var t=this.tryEntries.length-1;t>=0;--t){var A=this.tryEntries[t];if(A.tryLoc===g){var e=A.completion;if("throw"===e.type){var C=e.arg;O(A)}return C}}throw new Error("illegal catch attempt")},delegateYield:function(g,A,e){return this.delegate={iterator:D(g),resultName:A,nextLoc:e},"next"===this.method&&(this.arg=t),c}},g}(g.exports);try{regeneratorRuntime=t}catch(g){"object"==typeof globalThis?globalThis.regeneratorRuntime=t:Function("r","regeneratorRuntime = r")(t)}}(um);var pm=um.exports,fm=wg,vm=Gg,ym=G,mm=MA,bm=e.TypeError,wm=function(g){return function(t,A,e,C){fm(A);var I=vm(t),i=ym(I),o=mm(I),n=g?o-1:0,r=g?-1:1;if(e<2)for(;;){if(n in i){C=i[n],n+=r;break}if(n+=r,g?n<0:o<=n)throw bm("Reduce of empty array with no initial value")}for(;g?n>=0:o>n;n+=r)n in i&&(C=A(C,i[n],n,I));return C}},km={left:wm(!1),right:wm(!0)},xm="process"==M(e.process),Em=km.left,Om=og,Tm=xm;bA({target:"Array",proto:!0,forced:!Od("reduce")||!Tm&&Om>79&&Om<83},{reduce:function(g){var t=arguments.length;return Em(this,g,t,t>1?arguments[1]:void 0)}});var Dm=Oe("Array").reduce,Nm=$,Rm=Dm,Pm=Array.prototype,Mm=function(g){var t=g.reduce;return g===Pm||Nm(Pm,g)&&t===Pm.reduce?Rm:t},Bm=Mm,zm=Cn,Zm=MA,Sm=Yt,Fm=e.TypeError,Gm=function(g,t,A,e,C,I,i,o){for(var n,r,s=C,a=0,d=!!i&&Sm(i,o);a<e;){if(a in A){if(n=d?d(A[a],a,t):A[a],I>0&&zm(n))r=Zm(n),s=Gm(g,t,n,r,s,I-1)-1;else{if(s>=9007199254740991)throw Fm("Exceed the acceptable array length");g[s]=n}s++}a++}return s},Lm=Gm,jm=wg,Vm=Gg,Ym=MA,Wm=zn;bA({target:"Array",proto:!0},{flatMap:function(g){var t,A=Vm(this),e=Ym(A);return jm(g),(t=Wm(A,0)).length=Lm(t,A,A,e,0,1,g,arguments.length>1?arguments[1]:void 0),t}});var Qm=Oe("Array").flatMap,_m=$,Um=Qm,Hm=Array.prototype,Km=function(g){var t=g.flatMap;return g===Hm||_m(Hm,g)&&t===Hm.flatMap?Um:t},Xm=Km;ay("Set",(function(g){return function(){return g(this,arguments.length?arguments[0]:void 0)}}),Ny);var Jm=U.Set,qm=Ja,$m=vo,gb=cn,tb=Math.floor,Ab=function(g,t){var A=g.length,e=tb(A/2);return A<8?eb(g,t):Cb(g,Ab(gb(g,0,e),t),Ab(gb(g,e),t),t)},eb=function(g,t){for(var A,e,C=g.length,I=1;I<C;){for(e=I,A=g[I];e&&t(g[e-1],A)>0;)g[e]=g[--e];e!==I++&&(g[e]=A)}return g},Cb=function(g,t,A,e){for(var C=t.length,I=A.length,i=0,o=0;i<C||o<I;)g[i+o]=i<C&&o<I?e(t[i],A[o])<=0?t[i++]:A[o++]:i<C?t[i++]:A[o++];return g},Ib=Ab,ib=gg.match(/firefox\/(\d+)/i),ob=!!ib&&+ib[1],nb=/MSIE|Trident/.test(gg),rb=gg.match(/AppleWebKit\/(\d+)\./),sb=!!rb&&+rb[1],ab=bA,db=u,hb=wg,lb=Gg,cb=MA,ub=tC,pb=C,fb=Ib,vb=Od,yb=ob,mb=nb,bb=og,wb=sb,kb=[],xb=db(kb.sort),Eb=db(kb.push),Ob=pb((function(){kb.sort(void 0)})),Tb=pb((function(){kb.sort(null)})),Db=vb("sort"),Nb=!pb((function(){if(bb)return bb<70;if(!(yb&&yb>3)){if(mb)return!0;if(wb)return wb<603;var g,t,A,e,C="";for(g=65;g<76;g++){switch(t=String.fromCharCode(g),g){case 66:case 69:case 70:case 72:A=3;break;case 68:case 71:A=4;break;default:A=2}for(e=0;e<47;e++)kb.push({k:t+e,v:A})}for(kb.sort((function(g,t){return t.v-g.v})),e=0;e<kb.length;e++)t=kb[e].k.charAt(0),C.charAt(C.length-1)!==t&&(C+=t);return"DGBEFHACIJK"!==C}}));ab({target:"Array",proto:!0,forced:Ob||!Tb||!Db||!Nb},{sort:function(g){void 0!==g&&hb(g);var t=lb(this);if(Nb)return void 0===g?xb(t):xb(t,g);var A,e,C=[],I=cb(t);for(e=0;e<I;e++)e in t&&Eb(C,t[e]);for(fb(C,function(g){return function(t,A){return void 0===A?-1:void 0===t?1:void 0!==g?+g(t,A)||0:ub(t)>ub(A)?1:-1}}(g)),A=C.length,e=0;e<A;)t[e]=C[e++];for(;e<I;)delete t[e++];return t}});var Rb,Pb=Oe("Array").sort,Mb=$,Bb=Pb,zb=Array.prototype,Zb=function(g){var t=g.sort;return g===zb||Mb(zb,g)&&t===zb.sort?Bb:t},Sb=Zb,Fb=Oe("Array").keys,Gb=qe,Lb=Vg,jb=$,Vb=Fb,Yb=Array.prototype,Wb={DOMTokenList:!0,NodeList:!0},Qb=function(g){var t=g.keys;return g===Yb||jb(Yb,g)&&t===Yb.keys||Lb(Wb,Gb(g))?Vb:t},_b=Oe("Array").values,Ub=qe,Hb=Vg,Kb=$,Xb=_b,Jb=Array.prototype,qb={DOMTokenList:!0,NodeList:!0},$b=function(g){var t=g.values;return g===Jb||Kb(Jb,g)&&t===Jb.values||Hb(qb,Ub(g))?Xb:t},gw=Oe("Array").entries,tw=qe,Aw=Vg,ew=$,Cw=gw,Iw=Array.prototype,iw={DOMTokenList:!0,NodeList:!0},ow=function(g){var t=g.entries;return g===Iw||ew(Iw,g)&&t===Iw.entries||Aw(iw,tw(g))?Cw:t},nw=new Uint8Array(16);function rw(){if(!Rb&&!(Rb="undefined"!=typeof crypto&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)||"undefined"!=typeof msCrypto&&"function"==typeof msCrypto.getRandomValues&&msCrypto.getRandomValues.bind(msCrypto)))throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return Rb(nw)}var sw=/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;function aw(g){return"string"==typeof g&&sw.test(g)}for(var dw,hw=[],lw=0;lw<256;++lw)hw.push((lw+256).toString(16).substr(1));function cw(g,t,A){var e=(g=g||{}).random||(g.rng||rw)();if(e[6]=15&e[6]|64,e[8]=63&e[8]|128,t){A=A||0;for(var C=0;C<16;++C)t[A+C]=e[C];return t}return function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,A=(hw[g[t+0]]+hw[g[t+1]]+hw[g[t+2]]+hw[g[t+3]]+"-"+hw[g[t+4]]+hw[g[t+5]]+"-"+hw[g[t+6]]+hw[g[t+7]]+"-"+hw[g[t+8]]+hw[g[t+9]]+"-"+hw[g[t+10]]+hw[g[t+11]]+hw[g[t+12]]+hw[g[t+13]]+hw[g[t+14]]+hw[g[t+15]]).toLowerCase();if(!aw(A))throw TypeError("Stringified UUID is invalid");return A}(e)}function uw(g,t){var A=yd(g);if(rs){var e=rs(g);t&&(e=Uh(e).call(e,(function(t){return ys(g,t).enumerable}))),A.push.apply(A,e)}return A}function pw(g){for(var t=1;t<arguments.length;t++){var A,e,C=null!=arguments[t]?arguments[t]:{};t%2?Sd(A=uw(Object(C),!0)).call(A,(function(t){Js(g,t,C[t])})):Rs?Fs(g,Rs(C)):Sd(e=uw(Object(C))).call(e,(function(t){_s(g,t,ys(C,t))}))}return g}function fw(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}function vw(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return yw(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return yw(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function yw(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}var mw=function(){function g(t,A,e){var C,I,i;Us(this,g),Js(this,"_source",void 0),Js(this,"_transformers",void 0),Js(this,"_target",void 0),Js(this,"_listeners",{add:Me(C=this._add).call(C,this),remove:Me(I=this._remove).call(I,this),update:Me(i=this._update).call(i,this)}),this._source=t,this._transformers=A,this._target=e}return Xs(g,[{key:"all",value:function(){return this._target.update(this._transformItems(this._source.get())),this}},{key:"start",value:function(){return this._source.on("add",this._listeners.add),this._source.on("remove",this._listeners.remove),this._source.on("update",this._listeners.update),this}},{key:"stop",value:function(){return this._source.off("add",this._listeners.add),this._source.off("remove",this._listeners.remove),this._source.off("update",this._listeners.update),this}},{key:"_transformItems",value:function(g){var t;return Bm(t=this._transformers).call(t,(function(g,t){return t(g)}),g)}},{key:"_add",value:function(g,t){null!=t&&this._target.add(this._transformItems(this._source.get(t.items)))}},{key:"_update",value:function(g,t){null!=t&&this._target.update(this._transformItems(this._source.get(t.items)))}},{key:"_remove",value:function(g,t){null!=t&&this._target.remove(this._transformItems(t.oldData))}}]),g}(),bw=function(){function g(t){Us(this,g),Js(this,"_source",void 0),Js(this,"_transformers",[]),this._source=t}return Xs(g,[{key:"filter",value:function(g){return this._transformers.push((function(t){return Uh(t).call(t,g)})),this}},{key:"map",value:function(g){return this._transformers.push((function(t){return pd(t).call(t,g)})),this}},{key:"flatMap",value:function(g){return this._transformers.push((function(t){return Xm(t).call(t,g)})),this}},{key:"to",value:function(g){return new mw(this._source,this._transformers,g)}}]),g}();function ww(g){return"string"==typeof g||"number"==typeof g}var kw=function(){function g(t){Us(this,g),Js(this,"delay",void 0),Js(this,"max",void 0),Js(this,"_queue",[]),Js(this,"_timeout",null),Js(this,"_extended",null),this.delay=null,this.max=1/0,this.setOptions(t)}return Xs(g,[{key:"setOptions",value:function(g){g&&void 0!==g.delay&&(this.delay=g.delay),g&&void 0!==g.max&&(this.max=g.max),this._flushIfNeeded()}},{key:"destroy",value:function(){if(this.flush(),this._extended){for(var g=this._extended.object,t=this._extended.methods,A=0;A<t.length;A++){var e=t[A];e.original?g[e.name]=e.original:delete g[e.name]}this._extended=null}}},{key:"replace",value:function(g,t){var A=this,e=g[t];if(!e)throw new Error("Method "+t+" undefined");g[t]=function(){for(var g=arguments.length,t=new Array(g),C=0;C<g;C++)t[C]=arguments[C];A.queue({args:t,fn:e,context:this})}}},{key:"queue",value:function(g){"function"==typeof g?this._queue.push({fn:g}):this._queue.push(g),this._flushIfNeeded()}},{key:"_flushIfNeeded",value:function(){var g=this;this._queue.length>this.max&&this.flush(),null!=this._timeout&&(clearTimeout(this._timeout),this._timeout=null),this.queue.length>0&&"number"==typeof this.delay&&(this._timeout=yc((function(){g.flush()}),this.delay))}},{key:"flush",value:function(){var g,t;Sd(g=hh(t=this._queue).call(t,0)).call(g,(function(g){g.fn.apply(g.context||g.fn,g.args||[])}))}}],[{key:"extend",value:function(t,A){var e=new g(A);if(void 0!==t.flush)throw new Error("Target object already has a property flush");t.flush=function(){e.flush()};var C=[{name:"flush",original:void 0}];if(A&&A.replace)for(var I=0;I<A.replace.length;I++){var i=A.replace[I];C.push({name:i,original:t[i]}),e.replace(t,i)}return e._extended={object:t,methods:C},e}}]),g}(),xw=function(){function g(){Us(this,g),Js(this,"_subscribers",{"*":[],add:[],remove:[],update:[]}),Js(this,"subscribe",g.prototype.on),Js(this,"unsubscribe",g.prototype.off)}return Xs(g,[{key:"_trigger",value:function(g,t,A){var e,C;if("*"===g)throw new Error("Cannot trigger event *");Sd(e=od(C=[]).call(C,gd(this._subscribers[g]),gd(this._subscribers["*"]))).call(e,(function(e){e(g,t,null!=A?A:null)}))}},{key:"on",value:function(g,t){"function"==typeof t&&this._subscribers[g].push(t)}},{key:"off",value:function(g,t){var A;this._subscribers[g]=Uh(A=this._subscribers[g]).call(A,(function(g){return g!==t}))}}]),g}();dw=qm;var Ew=function(){function g(t){Us(this,g),Js(this,"_pairs",void 0),this._pairs=t}return Xs(g,[{key:dw,value:pm.mark((function g(){var t,A,e,C,I;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:t=vw(this._pairs),g.prev=1,t.s();case 3:if((A=t.n()).done){g.next=9;break}return e=Xa(A.value,2),C=e[0],I=e[1],g.next=7,[C,I];case 7:g.next=3;break;case 9:g.next=14;break;case 11:g.prev=11,g.t0=g.catch(1),t.e(g.t0);case 14:return g.prev=14,t.f(),g.finish(14);case 17:case"end":return g.stop()}}),g,this,[[1,11,14,17]])}))},{key:"entries",value:pm.mark((function g(){var t,A,e,C,I;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:t=vw(this._pairs),g.prev=1,t.s();case 3:if((A=t.n()).done){g.next=9;break}return e=Xa(A.value,2),C=e[0],I=e[1],g.next=7,[C,I];case 7:g.next=3;break;case 9:g.next=14;break;case 11:g.prev=11,g.t0=g.catch(1),t.e(g.t0);case 14:return g.prev=14,t.f(),g.finish(14);case 17:case"end":return g.stop()}}),g,this,[[1,11,14,17]])}))},{key:"keys",value:pm.mark((function g(){var t,A,e,C;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:t=vw(this._pairs),g.prev=1,t.s();case 3:if((A=t.n()).done){g.next=9;break}return e=Xa(A.value,1),C=e[0],g.next=7,C;case 7:g.next=3;break;case 9:g.next=14;break;case 11:g.prev=11,g.t0=g.catch(1),t.e(g.t0);case 14:return g.prev=14,t.f(),g.finish(14);case 17:case"end":return g.stop()}}),g,this,[[1,11,14,17]])}))},{key:"values",value:pm.mark((function g(){var t,A,e,C;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:t=vw(this._pairs),g.prev=1,t.s();case 3:if((A=t.n()).done){g.next=9;break}return e=Xa(A.value,2),C=e[1],g.next=7,C;case 7:g.next=3;break;case 9:g.next=14;break;case 11:g.prev=11,g.t0=g.catch(1),t.e(g.t0);case 14:return g.prev=14,t.f(),g.finish(14);case 17:case"end":return g.stop()}}),g,this,[[1,11,14,17]])}))},{key:"toIdArray",value:function(){var g;return pd(g=gd(this._pairs)).call(g,(function(g){return g[0]}))}},{key:"toItemArray",value:function(){var g;return pd(g=gd(this._pairs)).call(g,(function(g){return g[1]}))}},{key:"toEntryArray",value:function(){return gd(this._pairs)}},{key:"toObjectMap",value:function(){var g,t=Vl(null),A=vw(this._pairs);try{for(A.s();!(g=A.n()).done;){var e=Xa(g.value,2),C=e[0],I=e[1];t[C]=I}}catch(g){A.e(g)}finally{A.f()}return t}},{key:"toMap",value:function(){return new Ry(this._pairs)}},{key:"toIdSet",value:function(){return new Jm(this.toIdArray())}},{key:"toItemSet",value:function(){return new Jm(this.toItemArray())}},{key:"cache",value:function(){return new g(gd(this._pairs))}},{key:"distinct",value:function(g){var t,A=new Jm,e=vw(this._pairs);try{for(e.s();!(t=e.n()).done;){var C=Xa(t.value,2),I=C[0],i=C[1];A.add(g(i,I))}}catch(g){e.e(g)}finally{e.f()}return A}},{key:"filter",value:function(t){var A=this._pairs;return new g(Js({},qm,pm.mark((function g(){var e,C,I,i,o;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:e=vw(A),g.prev=1,e.s();case 3:if((C=e.n()).done){g.next=10;break}if(I=Xa(C.value,2),i=I[0],o=I[1],!t(o,i)){g.next=8;break}return g.next=8,[i,o];case 8:g.next=3;break;case 10:g.next=15;break;case 12:g.prev=12,g.t0=g.catch(1),e.e(g.t0);case 15:return g.prev=15,e.f(),g.finish(15);case 18:case"end":return g.stop()}}),g,null,[[1,12,15,18]])}))))}},{key:"forEach",value:function(g){var t,A=vw(this._pairs);try{for(A.s();!(t=A.n()).done;){var e=Xa(t.value,2),C=e[0];g(e[1],C)}}catch(g){A.e(g)}finally{A.f()}}},{key:"map",value:function(t){var A=this._pairs;return new g(Js({},qm,pm.mark((function g(){var e,C,I,i,o;return pm.wrap((function(g){for(;;)switch(g.prev=g.next){case 0:e=vw(A),g.prev=1,e.s();case 3:if((C=e.n()).done){g.next=9;break}return I=Xa(C.value,2),i=I[0],o=I[1],g.next=7,[i,t(o,i)];case 7:g.next=3;break;case 9:g.next=14;break;case 11:g.prev=11,g.t0=g.catch(1),e.e(g.t0);case 14:return g.prev=14,e.f(),g.finish(14);case 17:case"end":return g.stop()}}),g,null,[[1,11,14,17]])}))))}},{key:"max",value:function(g){var t=$m(this._pairs),A=t.next();if(A.done)return null;for(var e=A.value[1],C=g(A.value[1],A.value[0]);!(A=t.next()).done;){var I=Xa(A.value,2),i=I[0],o=I[1],n=g(o,i);n>C&&(C=n,e=o)}return e}},{key:"min",value:function(g){var t=$m(this._pairs),A=t.next();if(A.done)return null;for(var e=A.value[1],C=g(A.value[1],A.value[0]);!(A=t.next()).done;){var I=Xa(A.value,2),i=I[0],o=I[1],n=g(o,i);n<C&&(C=n,e=o)}return e}},{key:"reduce",value:function(g,t){var A,e=vw(this._pairs);try{for(e.s();!(A=e.n()).done;){var C=Xa(A.value,2),I=C[0];t=g(t,C[1],I)}}catch(g){e.e(g)}finally{e.f()}return t}},{key:"sort",value:function(t){var A=this;return new g(Js({},qm,(function(){var g;return $m(Sb(g=gd(A._pairs)).call(g,(function(g,A){var e=Xa(g,2),C=e[0],I=e[1],i=Xa(A,2),o=i[0],n=i[1];return t(I,n,C,o)})))})))}}]),g}();var Ow=function(g){dm(A,g);var t=fw(A);function A(g,e){var C;return Us(this,A),Js(nm(C=t.call(this)),"flush",void 0),Js(nm(C),"length",void 0),Js(nm(C),"_options",void 0),Js(nm(C),"_data",void 0),Js(nm(C),"_idProp",void 0),Js(nm(C),"_queue",null),g&&!sd(g)&&(e=g,g=[]),C._options=e||{},C._data=new Ry,C.length=0,C._idProp=C._options.fieldId||"id",g&&g.length&&C.add(g),C.setOptions(e),C}return Xs(A,[{key:"idProp",get:function(){return this._idProp}},{key:"setOptions",value:function(g){g&&void 0!==g.queue&&(!1===g.queue?this._queue&&(this._queue.destroy(),this._queue=null):(this._queue||(this._queue=kw.extend(this,{replace:["add","update","remove"]})),g.queue&&"object"===$a(g.queue)&&this._queue.setOptions(g.queue)))}},{key:"add",value:function(g,t){var A,e=this,C=[];if(sd(g)){var I=pd(g).call(g,(function(g){return g[e._idProp]}));if(Yy(I).call(I,(function(g){return e._data.has(g)})))throw new Error("A duplicate id was found in the parameter array.");for(var i=0,o=g.length;i<o;i++)A=this._addItem(g[i]),C.push(A)}else{if(!g||"object"!==$a(g))throw new Error("Unknown dataType");A=this._addItem(g),C.push(A)}return C.length&&this._trigger("add",{items:C},t),C}},{key:"update",value:function(g,t){var A=this,e=[],C=[],I=[],i=[],o=this._idProp,n=function(g){var t=g[o];if(null!=t&&A._data.has(t)){var n=g,r=ae({},A._data.get(t)),s=A._updateItem(n);C.push(s),i.push(n),I.push(r)}else{var a=A._addItem(g);e.push(a)}};if(sd(g))for(var r=0,s=g.length;r<s;r++)g[r]&&"object"===$a(g[r])?n(g[r]):console.warn("Ignoring input item, which is not an object at index "+r);else{if(!g||"object"!==$a(g))throw new Error("Unknown dataType");n(g)}if(e.length&&this._trigger("add",{items:e},t),C.length){var a={items:C,oldData:I,data:i};this._trigger("update",a,t)}return od(e).call(e,C)}},{key:"updateOnly",value:function(g,t){var A,e=this;sd(g)||(g=[g]);var C=pd(A=pd(g).call(g,(function(g){var t=e._data.get(g[e._idProp]);if(null==t)throw new Error("Updating non-existent items is not allowed.");return{oldData:t,update:g}}))).call(A,(function(g){var t=g.oldData,A=g.update,C=t[e._idProp],I=function(g){for(var t,A=arguments.length,e=new Array(A>1?A-1:0),C=1;C<A;C++)e[C-1]=arguments[C];return kp.apply(void 0,od(t=[{},g]).call(t,e))}(t,A);return e._data.set(C,I),{id:C,oldData:t,updatedData:I}}));if(C.length){var I={items:pd(C).call(C,(function(g){return g.id})),oldData:pd(C).call(C,(function(g){return g.oldData})),data:pd(C).call(C,(function(g){return g.updatedData}))};return this._trigger("update",I,t),I.items}return[]}},{key:"get",value:function(g,t){var A=void 0,e=void 0,C=void 0;ww(g)?(A=g,C=t):sd(g)?(e=g,C=t):C=g;var I,i=C&&"Object"===C.returnType?"Object":"Array",o=C&&Uh(C),n=[],r=void 0,s=void 0,a=void 0;if(null!=A)(r=this._data.get(A))&&o&&!o(r)&&(r=void 0);else if(null!=e)for(var d=0,h=e.length;d<h;d++)null==(r=this._data.get(e[d]))||o&&!o(r)||n.push(r);else for(var l,c=0,u=(s=gd(Qb(l=this._data).call(l))).length;c<u;c++)a=s[c],null==(r=this._data.get(a))||o&&!o(r)||n.push(r);if(C&&C.order&&null==A&&this._sort(n,C.order),C&&C.fields){var p=C.fields;if(null!=A&&null!=r)r=this._filterFields(r,p);else for(var f=0,v=n.length;f<v;f++)n[f]=this._filterFields(n[f],p)}if("Object"==i){for(var y={},m=0,b=n.length;m<b;m++){var w=n[m];y[w[this._idProp]]=w}return y}return null!=A?null!==(I=r)&&void 0!==I?I:null:n}},{key:"getIds",value:function(g){var t=this._data,A=g&&Uh(g),e=g&&g.order,C=gd(Qb(t).call(t)),I=[];if(A)if(e){for(var i=[],o=0,n=C.length;o<n;o++){var r=C[o],s=this._data.get(r);null!=s&&A(s)&&i.push(s)}this._sort(i,e);for(var a=0,d=i.length;a<d;a++)I.push(i[a][this._idProp])}else for(var h=0,l=C.length;h<l;h++){var c=C[h],u=this._data.get(c);null!=u&&A(u)&&I.push(u[this._idProp])}else if(e){for(var p=[],f=0,v=C.length;f<v;f++){var y=C[f];p.push(t.get(y))}this._sort(p,e);for(var m=0,b=p.length;m<b;m++)I.push(p[m][this._idProp])}else for(var w=0,k=C.length;w<k;w++){var x=C[w],E=t.get(x);null!=E&&I.push(E[this._idProp])}return I}},{key:"getDataSet",value:function(){return this}},{key:"forEach",value:function(g,t){var A=t&&Uh(t),e=this._data,C=gd(Qb(e).call(e));if(t&&t.order)for(var I=this.get(t),i=0,o=I.length;i<o;i++){var n=I[i];g(n,n[this._idProp])}else for(var r=0,s=C.length;r<s;r++){var a=C[r],d=this._data.get(a);null==d||A&&!A(d)||g(d,a)}}},{key:"map",value:function(g,t){for(var A=t&&Uh(t),e=[],C=this._data,I=gd(Qb(C).call(C)),i=0,o=I.length;i<o;i++){var n=I[i],r=this._data.get(n);null==r||A&&!A(r)||e.push(g(r,n))}return t&&t.order&&this._sort(e,t.order),e}},{key:"_filterFields",value:function(g,t){var A;return g?Bm(A=sd(t)?t:yd(t)).call(A,(function(t,A){return t[A]=g[A],t}),{}):g}},{key:"_sort",value:function(g,t){if("string"==typeof t){var A=t;Sb(g).call(g,(function(g,t){var e=g[A],C=t[A];return e>C?1:e<C?-1:0}))}else{if("function"!=typeof t)throw new TypeError("Order must be a function or a string");Sb(g).call(g,t)}}},{key:"remove",value:function(g,t){for(var A=[],e=[],C=sd(g)?g:[g],I=0,i=C.length;I<i;I++){var o=this._remove(C[I]);if(o){var n=o[this._idProp];null!=n&&(A.push(n),e.push(o))}}return A.length&&this._trigger("remove",{items:A,oldData:e},t),A}},{key:"_remove",value:function(g){var t;if(ww(g)?t=g:g&&"object"===$a(g)&&(t=g[this._idProp]),null!=t&&this._data.has(t)){var A=this._data.get(t)||null;return this._data.delete(t),--this.length,A}return null}},{key:"clear",value:function(g){for(var t,A=gd(Qb(t=this._data).call(t)),e=[],C=0,I=A.length;C<I;C++)e.push(this._data.get(A[C]));return this._data.clear(),this.length=0,this._trigger("remove",{items:A,oldData:e},g),A}},{key:"max",value:function(g){var t,A,e=null,C=null,I=vw($b(t=this._data).call(t));try{for(I.s();!(A=I.n()).done;){var i=A.value,o=i[g];"number"==typeof o&&(null==C||o>C)&&(e=i,C=o)}}catch(g){I.e(g)}finally{I.f()}return e||null}},{key:"min",value:function(g){var t,A,e=null,C=null,I=vw($b(t=this._data).call(t));try{for(I.s();!(A=I.n()).done;){var i=A.value,o=i[g];"number"==typeof o&&(null==C||o<C)&&(e=i,C=o)}}catch(g){I.e(g)}finally{I.f()}return e||null}},{key:"distinct",value:function(g){for(var t=this._data,A=gd(Qb(t).call(t)),e=[],C=0,I=0,i=A.length;I<i;I++){for(var o=A[I],n=t.get(o)[g],r=!1,s=0;s<C;s++)if(e[s]==n){r=!0;break}r||void 0===n||(e[C]=n,C++)}return e}},{key:"_addItem",value:function(g){var t=function(g,t){return null==g[t]&&(g[t]=cw()),g}(g,this._idProp),A=t[this._idProp];if(this._data.has(A))throw new Error("Cannot add item: item with id "+A+" already exists");return this._data.set(A,t),++this.length,A}},{key:"_updateItem",value:function(g){var t=g[this._idProp];if(null==t)throw new Error("Cannot update item: item has no id (item: "+rc(g)+")");var A=this._data.get(t);if(!A)throw new Error("Cannot update item: no item with id "+t+" found");return this._data.set(t,pw(pw({},A),g)),t}},{key:"stream",value:function(g){if(g){var t=this._data;return new Ew(Js({},qm,pm.mark((function A(){var e,C,I,i;return pm.wrap((function(A){for(;;)switch(A.prev=A.next){case 0:e=vw(g),A.prev=1,e.s();case 3:if((C=e.n()).done){A.next=11;break}if(I=C.value,null==(i=t.get(I))){A.next=9;break}return A.next=9,[I,i];case 9:A.next=3;break;case 11:A.next=16;break;case 13:A.prev=13,A.t0=A.catch(1),e.e(A.t0);case 16:return A.prev=16,e.f(),A.finish(16);case 19:case"end":return A.stop()}}),A,null,[[1,13,16,19]])}))))}var A;return new Ew(Js({},qm,Me(A=ow(this._data)).call(A,this._data)))}}]),A}(xw),Tw=function(g){dm(A,g);var t=fw(A);function A(g,e){var C,I;return Us(this,A),Js(nm(I=t.call(this)),"length",0),Js(nm(I),"_listener",void 0),Js(nm(I),"_data",void 0),Js(nm(I),"_ids",new Jm),Js(nm(I),"_options",void 0),I._options=e||{},I._listener=Me(C=I._onEvent).call(C,nm(I)),I.setData(g),I}return Xs(A,[{key:"idProp",get:function(){return this.getDataSet().idProp}},{key:"setData",value:function(g){if(this._data){this._data.off&&this._data.off("*",this._listener);var t=this._data.getIds({filter:Uh(this._options)}),A=this._data.get(t);this._ids.clear(),this.length=0,this._trigger("remove",{items:t,oldData:A})}if(null!=g){this._data=g;for(var e=this._data.getIds({filter:Uh(this._options)}),C=0,I=e.length;C<I;C++){var i=e[C];this._ids.add(i)}this.length=e.length,this._trigger("add",{items:e})}else this._data=new Ow;this._data.on&&this._data.on("*",this._listener)}},{key:"refresh",value:function(){for(var g=this._data.getIds({filter:Uh(this._options)}),t=gd(this._ids),A={},e=[],C=[],I=[],i=0,o=g.length;i<o;i++){var n=g[i];A[n]=!0,this._ids.has(n)||(e.push(n),this._ids.add(n))}for(var r=0,s=t.length;r<s;r++){var a=t[r],d=this._data.get(a);null==d?console.error("If you see this, report it please."):A[a]||(C.push(a),I.push(d),this._ids.delete(a))}this.length+=e.length-C.length,e.length&&this._trigger("add",{items:e}),C.length&&this._trigger("remove",{items:C,oldData:I})}},{key:"get",value:function(g,t){if(null==this._data)return null;var A,e=null;ww(g)||sd(g)?(e=g,A=t):A=g;var C=ae({},this._options,A),I=Uh(this._options),i=A&&Uh(A);return I&&i&&(C.filter=function(g){return I(g)&&i(g)}),null==e?this._data.get(C):this._data.get(e,C)}},{key:"getIds",value:function(g){if(this._data.length){var t,A=Uh(this._options),e=null!=g?Uh(g):null;return t=e?A?function(g){return A(g)&&e(g)}:e:A,this._data.getIds({filter:t,order:g&&g.order})}return[]}},{key:"forEach",value:function(g,t){if(this._data){var A,e,C=Uh(this._options),I=t&&Uh(t);e=I?C?function(g){return C(g)&&I(g)}:I:C,Sd(A=this._data).call(A,g,{filter:e,order:t&&t.order})}}},{key:"map",value:function(g,t){if(this._data){var A,e,C=Uh(this._options),I=t&&Uh(t);return e=I?C?function(g){return C(g)&&I(g)}:I:C,pd(A=this._data).call(A,g,{filter:e,order:t&&t.order})}return[]}},{key:"getDataSet",value:function(){return this._data.getDataSet()}},{key:"stream",value:function(g){var t;return this._data.stream(g||Js({},qm,Me(t=Qb(this._ids)).call(t,this._ids)))}},{key:"dispose",value:function(){var g;null!==(g=this._data)&&void 0!==g&&g.off&&this._data.off("*",this._listener);var t,e="This data view has already been disposed of.",C={get:function(){throw new Error(e)},set:function(){throw new Error(e)},configurable:!1},I=vw(rd(A.prototype));try{for(I.s();!(t=I.n()).done;){var i=t.value;_s(this,i,C)}}catch(g){I.e(g)}finally{I.f()}}},{key:"_onEvent",value:function(g,t,A){if(t&&t.items&&this._data){var e=t.items,C=[],I=[],i=[],o=[],n=[],r=[];switch(g){case"add":for(var s=0,a=e.length;s<a;s++){var d=e[s];this.get(d)&&(this._ids.add(d),C.push(d))}break;case"update":for(var h=0,l=e.length;h<l;h++){var c=e[h];this.get(c)?this._ids.has(c)?(I.push(c),n.push(t.data[h]),o.push(t.oldData[h])):(this._ids.add(c),C.push(c)):this._ids.has(c)&&(this._ids.delete(c),i.push(c),r.push(t.oldData[h]))}break;case"remove":for(var u=0,p=e.length;u<p;u++){var f=e[u];this._ids.has(f)&&(this._ids.delete(f),i.push(f),r.push(t.oldData[u]))}}this.length+=C.length-i.length,C.length&&this._trigger("add",{items:C},A),I.length&&this._trigger("update",{items:I,oldData:o,data:n},A),i.length&&this._trigger("remove",{items:i,oldData:r},A)}}}]),A}(xw);function Dw(g,t){return"object"===$a(t)&&null!==t&&g===t.idProp&&"function"==typeof t.add&&"function"==typeof t.clear&&"function"==typeof t.distinct&&"function"==typeof Sd(t)&&"function"==typeof t.get&&"function"==typeof t.getDataSet&&"function"==typeof t.getIds&&"number"==typeof t.length&&"function"==typeof pd(t)&&"function"==typeof t.max&&"function"==typeof t.min&&"function"==typeof t.off&&"function"==typeof t.on&&"function"==typeof t.remove&&"function"==typeof t.setOptions&&"function"==typeof t.stream&&"function"==typeof t.update&&"function"==typeof t.updateOnly}function Nw(g,t){return"object"===$a(t)&&null!==t&&g===t.idProp&&"function"==typeof Sd(t)&&"function"==typeof t.get&&"function"==typeof t.getDataSet&&"function"==typeof t.getIds&&"number"==typeof t.length&&"function"==typeof pd(t)&&"function"==typeof t.off&&"function"==typeof t.on&&"function"==typeof t.stream&&Dw(g,t.getDataSet())}var Rw=Object.freeze({__proto__:null,DELETE:wp,DataSet:Ow,DataStream:Ew,DataView:Tw,Queue:kw,createNewDataPipeFrom:function(g){return new bw(g)},isDataSetLike:Dw,isDataViewLike:Nw}),Pw=e,Mw=C,Bw=tC,zw=sl.trim,Zw=u("".charAt),Sw=Pw.parseFloat,Fw=Pw.Symbol,Gw=Fw&&Fw.iterator,Lw=1/Sw("\t\n\v\f\r                　\u2028\u2029\ufeff-0")!=-1/0||Gw&&!Mw((function(){Sw(Object(Gw))}))?function(g){var t=zw(Bw(g)),A=Sw(t);return 0===A&&"-"==Zw(t,0)?-0:A}:Sw;bA({global:!0,forced:parseFloat!=Lw},{parseFloat:Lw});var jw=U.parseFloat,Vw=bA,Yw=C,Ww=rn.f;Vw({target:"Object",stat:!0,forced:Yw((function(){return!Object.getOwnPropertyNames(1)}))},{getOwnPropertyNames:Ww});var Qw=U.Object,_w=function(g){return Qw.getOwnPropertyNames(g)},Uw=_w;function Hw(g,t){var A=["node","edge","label"],e=!0,C=rf(t,"chosen");if("boolean"==typeof C)e=C;else if("object"===$a(C)){if(-1===zl(A).call(A,g))throw new Error("choosify: subOption '"+g+"' should be one of '"+A.join("', '")+"'");var I=rf(t,["chosen",g]);"boolean"!=typeof I&&"function"!=typeof I||(e=I)}return e}function Kw(g,t,A){if(g.width<=0||g.height<=0)return!1;if(void 0!==A){var e={x:t.x-A.x,y:t.y-A.y};if(0!==A.angle){var C=-A.angle;t={x:Math.cos(C)*e.x-Math.sin(C)*e.y,y:Math.sin(C)*e.x+Math.cos(C)*e.y}}else t=e}var I=g.x+g.width,i=g.y+g.width;return g.left<t.x&&I>t.x&&g.top<t.y&&i>t.y}function Xw(g){return"string"==typeof g&&""!==g}function Jw(g,t,A,e){var C=e.x,I=e.y;if("function"==typeof e.distanceToBorder){var i=e.distanceToBorder(g,t),o=Math.sin(t)*i,n=Math.cos(t)*i;n===i?(C+=i,I=e.y):o===i?(C=e.x,I-=i):(C+=n,I-=o)}else e.shape.width>e.shape.height?(C=e.x+.5*e.shape.width,I=e.y-A):(C=e.x+A,I=e.y-.5*e.shape.height);return{x:C,y:I}}var qw=function(){function g(t){Us(this,g),this.measureText=t,this.current=0,this.width=0,this.height=0,this.lines=[]}return Xs(g,[{key:"_add",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"normal";void 0===this.lines[g]&&(this.lines[g]={width:0,height:0,blocks:[]});var e=t;void 0!==t&&""!==t||(e=" ");var C=this.measureText(e,A),I=ae({},$b(C));I.text=t,I.width=C.width,I.mod=A,void 0!==t&&""!==t||(I.width=0),this.lines[g].blocks.push(I),this.lines[g].width+=I.width}},{key:"curWidth",value:function(){var g=this.lines[this.current];return void 0===g?0:g.width}},{key:"append",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"normal";this._add(this.current,g,t)}},{key:"newLine",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"normal";this._add(this.current,g,t),this.current++}},{key:"determineLineHeights",value:function(){for(var g=0;g<this.lines.length;g++){var t=this.lines[g],A=0;if(void 0!==t.blocks)for(var e=0;e<t.blocks.length;e++){var C=t.blocks[e];A<C.height&&(A=C.height)}t.height=A}}},{key:"determineLabelSize",value:function(){for(var g=0,t=0,A=0;A<this.lines.length;A++){var e=this.lines[A];e.width>g&&(g=e.width),t+=e.height}this.width=g,this.height=t}},{key:"removeEmptyBlocks",value:function(){for(var g=[],t=0;t<this.lines.length;t++){var A=this.lines[t];if(0!==A.blocks.length&&(t!==this.lines.length-1||0!==A.width)){var e={};ae(e,A),e.blocks=[];for(var C=void 0,I=[],i=0;i<A.blocks.length;i++){var o=A.blocks[i];0!==o.width?I.push(o):void 0===C&&(C=o)}0===I.length&&void 0!==C&&I.push(C),e.blocks=I,g.push(e)}}return g}},{key:"finalize",value:function(){this.determineLineHeights(),this.determineLabelSize();var g=this.removeEmptyBlocks();return{width:this.width,height:this.height,lines:g}}}]),g}(),$w={"<b>":/<b>/,"<i>":/<i>/,"<code>":/<code>/,"</b>":/<\/b>/,"</i>":/<\/i>/,"</code>":/<\/code>/,"*":/\*/,_:/_/,"`":/`/,afterBold:/[^*]/,afterItal:/[^_]/,afterMono:/[^`]/},gk=function(){function g(t){Us(this,g),this.text=t,this.bold=!1,this.ital=!1,this.mono=!1,this.spacing=!1,this.position=0,this.buffer="",this.modStack=[],this.blocks=[]}return Xs(g,[{key:"mod",value:function(){return 0===this.modStack.length?"normal":this.modStack[0]}},{key:"modName",value:function(){return 0===this.modStack.length?"normal":"mono"===this.modStack[0]?"mono":this.bold&&this.ital?"boldital":this.bold?"bold":this.ital?"ital":void 0}},{key:"emitBlock",value:function(){this.spacing&&(this.add(" "),this.spacing=!1),this.buffer.length>0&&(this.blocks.push({text:this.buffer,mod:this.modName()}),this.buffer="")}},{key:"add",value:function(g){" "===g&&(this.spacing=!0),this.spacing&&(this.buffer+=" ",this.spacing=!1)," "!=g&&(this.buffer+=g)}},{key:"parseWS",value:function(g){return!!/[ \t]/.test(g)&&(this.mono?this.add(g):this.spacing=!0,!0)}},{key:"setTag",value:function(g){this.emitBlock(),this[g]=!0,this.modStack.unshift(g)}},{key:"unsetTag",value:function(g){this.emitBlock(),this[g]=!1,this.modStack.shift()}},{key:"parseStartTag",value:function(g,t){return!(this.mono||this[g]||!this.match(t))&&(this.setTag(g),!0)}},{key:"match",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],A=this.prepareRegExp(g),e=Xa(A,2),C=e[0],I=e[1],i=C.test(this.text.substr(this.position,I));return i&&t&&(this.position+=I-1),i}},{key:"parseEndTag",value:function(g,t,A){var e=this.mod()===g;return!(!(e="mono"===g?e&&this.mono:e&&!this.mono)||!this.match(t))&&(void 0!==A?(this.position===this.text.length-1||this.match(A,!1))&&this.unsetTag(g):this.unsetTag(g),!0)}},{key:"replace",value:function(g,t){return!!this.match(g)&&(this.add(t),this.position+=length-1,!0)}},{key:"prepareRegExp",value:function(g){var t,A;if(g instanceof RegExp)A=g,t=1;else{var e=$w[g];A=void 0!==e?e:new RegExp(g),t=g.length}return[A,t]}}]),g}(),tk=function(){function g(t,A,e,C){var I=this;Us(this,g),this.ctx=t,this.parent=A,this.selected=e,this.hover=C;this.lines=new qw((function(g,A){if(void 0===g)return 0;var i=I.parent.getFormattingValues(t,e,C,A),o=0;""!==g&&(o=I.ctx.measureText(g).width);return{width:o,values:i}}))}return Xs(g,[{key:"process",value:function(g){if(!Xw(g))return this.lines.finalize();var t=this.parent.fontOptions;g=(g=g.replace(/\r\n/g,"\n")).replace(/\r/g,"\n");var A=String(g).split("\n"),e=A.length;if(t.multi)for(var C=0;C<e;C++){var I=this.splitBlocks(A[C],t.multi);if(void 0!==I)if(0!==I.length){if(t.maxWdt>0)for(var i=0;i<I.length;i++){var o=I[i].mod,n=I[i].text;this.splitStringIntoLines(n,o,!0)}else for(var r=0;r<I.length;r++){var s=I[r].mod,a=I[r].text;this.lines.append(a,s)}this.lines.newLine()}else this.lines.newLine("")}else if(t.maxWdt>0)for(var d=0;d<e;d++)this.splitStringIntoLines(A[d]);else for(var h=0;h<e;h++)this.lines.newLine(A[h]);return this.lines.finalize()}},{key:"decodeMarkupSystem",value:function(g){var t="none";return"markdown"===g||"md"===g?t="markdown":!0!==g&&"html"!==g||(t="html"),t}},{key:"splitHtmlBlocks",value:function(g){for(var t=new gk(g),A=function(g){return!!/&/.test(g)&&(t.replace(t.text,"&lt;","<")||t.replace(t.text,"&amp;","&")||t.add("&"),!0)};t.position<t.text.length;){var e=t.text.charAt(t.position);t.parseWS(e)||/</.test(e)&&(t.parseStartTag("bold","<b>")||t.parseStartTag("ital","<i>")||t.parseStartTag("mono","<code>")||t.parseEndTag("bold","</b>")||t.parseEndTag("ital","</i>")||t.parseEndTag("mono","</code>"))||A(e)||t.add(e),t.position++}return t.emitBlock(),t.blocks}},{key:"splitMarkdownBlocks",value:function(g){for(var t=this,A=new gk(g),e=!0,C=function(g){return!!/\\/.test(g)&&(A.position<t.text.length+1&&(A.position++,g=t.text.charAt(A.position),/ \t/.test(g)?A.spacing=!0:(A.add(g),e=!1)),!0)};A.position<A.text.length;){var I=A.text.charAt(A.position);A.parseWS(I)||C(I)||(e||A.spacing)&&(A.parseStartTag("bold","*")||A.parseStartTag("ital","_")||A.parseStartTag("mono","`"))||A.parseEndTag("bold","*","afterBold")||A.parseEndTag("ital","_","afterItal")||A.parseEndTag("mono","`","afterMono")||(A.add(I),e=!1),A.position++}return A.emitBlock(),A.blocks}},{key:"splitBlocks",value:function(g,t){var A=this.decodeMarkupSystem(t);return"none"===A?[{text:g,mod:"normal"}]:"markdown"===A?this.splitMarkdownBlocks(g):"html"===A?this.splitHtmlBlocks(g):void 0}},{key:"overMaxWidth",value:function(g){var t=this.ctx.measureText(g).width;return this.lines.curWidth()+t>this.parent.fontOptions.maxWdt}},{key:"getLongestFit",value:function(g){for(var t="",A=0;A<g.length;){var e=t+(""===t?"":" ")+g[A];if(this.overMaxWidth(e))break;t=e,A++}return A}},{key:"getLongestFitWord",value:function(g){for(var t=0;t<g.length&&!this.overMaxWidth(nd(g).call(g,0,t));)t++;return t}},{key:"splitStringIntoLines",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"normal",A=arguments.length>2&&void 0!==arguments[2]&&arguments[2];this.parent.getFormattingValues(this.ctx,this.selected,this.hover,t);for(var e=(g=(g=g.replace(/^( +)/g,"$1\r")).replace(/([^\r][^ ]*)( +)/g,"$1\r$2\r")).split("\r");e.length>0;){var C=this.getLongestFit(e);if(0===C){var I=e[0],i=this.getLongestFitWord(I);this.lines.newLine(nd(I).call(I,0,i),t),e[0]=nd(I).call(I,i)}else{var o=C;" "===e[C-1]?C--:" "===e[o]&&o++;var n=nd(e).call(e,0,C).join("");C==e.length&&A?this.lines.append(n,t):this.lines.newLine(n,t),e=nd(e).call(e,o)}}}}]),g}(),Ak=["bold","ital","boldital","mono"],ek=function(){function g(t,A){var e=arguments.length>2&&void 0!==arguments[2]&&arguments[2];Us(this,g),this.body=t,this.pointToSelf=!1,this.baseSize=void 0,this.fontOptions={},this.setOptions(A),this.size={top:0,left:0,width:0,height:0,yLine:0},this.isEdgeLabel=e}return Xs(g,[{key:"setOptions",value:function(g){if(this.elementOptions=g,this.initFontOptions(g.font),Xw(g.label)?this.labelDirty=!0:g.label=void 0,void 0!==g.font&&null!==g.font)if("string"==typeof g.font)this.baseSize=this.fontOptions.size;else if("object"===$a(g.font)){var t=g.font.size;void 0!==t&&(this.baseSize=t)}}},{key:"initFontOptions",value:function(t){var A=this;Hp(Ak,(function(g){A.fontOptions[g]={}})),g.parseFontString(this.fontOptions,t)?this.fontOptions.vadjust=0:Hp(t,(function(g,t){null!=g&&"object"!==$a(g)&&(A.fontOptions[t]=g)}))}},{key:"constrain",value:function(g){var t={constrainWidth:!1,maxWdt:-1,minWdt:-1,constrainHeight:!1,minHgt:-1,valign:"middle"},A=rf(g,"widthConstraint");if("number"==typeof A)t.maxWdt=Number(A),t.minWdt=Number(A);else if("object"===$a(A)){var e=rf(g,["widthConstraint","maximum"]);"number"==typeof e&&(t.maxWdt=Number(e));var C=rf(g,["widthConstraint","minimum"]);"number"==typeof C&&(t.minWdt=Number(C))}var I=rf(g,"heightConstraint");if("number"==typeof I)t.minHgt=Number(I);else if("object"===$a(I)){var i=rf(g,["heightConstraint","minimum"]);"number"==typeof i&&(t.minHgt=Number(i));var o=rf(g,["heightConstraint","valign"]);"string"==typeof o&&("top"!==o&&"bottom"!==o||(t.valign=o))}return t}},{key:"update",value:function(g,t){this.setOptions(g,!0),this.propagateFonts(t),Wp(this.fontOptions,this.constrain(t)),this.fontOptions.chooser=Hw("label",t)}},{key:"adjustSizes",value:function(g){var t=g?g.right+g.left:0;this.fontOptions.constrainWidth&&(this.fontOptions.maxWdt-=t,this.fontOptions.minWdt-=t);var A=g?g.top+g.bottom:0;this.fontOptions.constrainHeight&&(this.fontOptions.minHgt-=A)}},{key:"addFontOptionsToPile",value:function(g,t){for(var A=0;A<t.length;++A)this.addFontToPile(g,t[A])}},{key:"addFontToPile",value:function(g,t){if(void 0!==t&&void 0!==t.font&&null!==t.font){var A=t.font;g.push(A)}}},{key:"getBasicOptions",value:function(t){for(var A={},e=0;e<t.length;++e){var C=t[e],I={};g.parseFontString(I,C)&&(C=I),Hp(C,(function(g,t){void 0!==g&&(Object.prototype.hasOwnProperty.call(A,t)||(-1!==zl(Ak).call(Ak,t)?A[t]={}:A[t]=g))}))}return A}},{key:"getFontOption",value:function(t,A,e){for(var C,I=0;I<t.length;++I){var i=t[I];if(Object.prototype.hasOwnProperty.call(i,A)){if(null==(C=i[A]))continue;var o={};if(g.parseFontString(o,C)&&(C=o),Object.prototype.hasOwnProperty.call(C,e))return C[e]}}if(Object.prototype.hasOwnProperty.call(this.fontOptions,e))return this.fontOptions[e];throw new Error("Did not find value for multi-font for property: '"+e+"'")}},{key:"getFontOptions",value:function(g,t){for(var A={},e=["color","size","face","mod","vadjust"],C=0;C<e.length;++C){var I=e[C];A[I]=this.getFontOption(g,t,I)}return A}},{key:"propagateFonts",value:function(g){var t=this,A=[];this.addFontOptionsToPile(A,g),this.fontOptions=this.getBasicOptions(A);for(var e=function(g){var e=Ak[g],C=t.fontOptions[e];Hp(t.getFontOptions(A,e),(function(g,t){C[t]=g})),C.size=Number(C.size),C.vadjust=Number(C.vadjust)},C=0;C<Ak.length;++C)e(C)}},{key:"draw",value:function(g,t,A,e,C){var I=arguments.length>5&&void 0!==arguments[5]?arguments[5]:"middle";if(void 0!==this.elementOptions.label){var i=this.fontOptions.size*this.body.view.scale;this.elementOptions.label&&i<this.elementOptions.scaling.label.drawThreshold-1||(i>=this.elementOptions.scaling.label.maxVisible&&(i=Number(this.elementOptions.scaling.label.maxVisible)/this.body.view.scale),this.calculateLabelSize(g,e,C,t,A,I),this._drawBackground(g),this._drawText(g,t,this.size.yLine,I,i))}}},{key:"_drawBackground",value:function(g){if(void 0!==this.fontOptions.background&&"none"!==this.fontOptions.background){g.fillStyle=this.fontOptions.background;var t=this.getSize();g.fillRect(t.left,t.top,t.width,t.height)}}},{key:"_drawText",value:function(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"middle",C=arguments.length>4?arguments[4]:void 0,I=this._setAlignment(g,t,A,e),i=Xa(I,2);t=i[0],A=i[1],g.textAlign="left",t-=this.size.width/2,this.fontOptions.valign&&this.size.height>this.size.labelHeight&&("top"===this.fontOptions.valign&&(A-=(this.size.height-this.size.labelHeight)/2),"bottom"===this.fontOptions.valign&&(A+=(this.size.height-this.size.labelHeight)/2));for(var o=0;o<this.lineCount;o++){var n=this.lines[o];if(n&&n.blocks){var r=0;this.isEdgeLabel||"center"===this.fontOptions.align?r+=(this.size.width-n.width)/2:"right"===this.fontOptions.align&&(r+=this.size.width-n.width);for(var s=0;s<n.blocks.length;s++){var a=n.blocks[s];g.font=a.font;var d=this._getColor(a.color,C,a.strokeColor),h=Xa(d,2),l=h[0],c=h[1];a.strokeWidth>0&&(g.lineWidth=a.strokeWidth,g.strokeStyle=c,g.lineJoin="round"),g.fillStyle=l,a.strokeWidth>0&&g.strokeText(a.text,t+r,A+a.vadjust),g.fillText(a.text,t+r,A+a.vadjust),r+=a.width}A+=n.height}}}},{key:"_setAlignment",value:function(g,t,A,e){if(this.isEdgeLabel&&"horizontal"!==this.fontOptions.align&&!1===this.pointToSelf){t=0,A=0;"top"===this.fontOptions.align?(g.textBaseline="alphabetic",A-=4):"bottom"===this.fontOptions.align?(g.textBaseline="hanging",A+=4):g.textBaseline="middle"}else g.textBaseline=e;return[t,A]}},{key:"_getColor",value:function(g,t,A){var e=g||"#000000",C=A||"#ffffff";if(t<=this.elementOptions.scaling.label.drawThreshold){var I=Math.max(0,Math.min(1,1-(this.elementOptions.scaling.label.drawThreshold-t)));e=Xp(e,I),C=Xp(C,I)}return[e,C]}},{key:"getTextSize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],A=arguments.length>2&&void 0!==arguments[2]&&arguments[2];return this._processLabel(g,t,A),{width:this.size.width,height:this.size.height,lineCount:this.lineCount}}},{key:"getSize",value:function(){var g=this.size.left,t=this.size.top-1;if(this.isEdgeLabel){var A=.5*-this.size.width;switch(this.fontOptions.align){case"middle":g=A,t=.5*-this.size.height;break;case"top":g=A,t=-(this.size.height+2);break;case"bottom":g=A,t=2}}return{left:g,top:t,width:this.size.width,height:this.size.height}}},{key:"calculateLabelSize",value:function(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:0,C=arguments.length>4&&void 0!==arguments[4]?arguments[4]:0,I=arguments.length>5&&void 0!==arguments[5]?arguments[5]:"middle";this._processLabel(g,t,A),this.size.left=e-.5*this.size.width,this.size.top=C-.5*this.size.height,this.size.yLine=C+.5*(1-this.lineCount)*this.fontOptions.size,"hanging"===I&&(this.size.top+=.5*this.fontOptions.size,this.size.top+=4,this.size.yLine+=4)}},{key:"getFormattingValues",value:function(g,t,A,e){var C=function(g,t,A){return"normal"===t?"mod"===A?"":g[A]:void 0!==g[t][A]?g[t][A]:g[A]},I={color:C(this.fontOptions,e,"color"),size:C(this.fontOptions,e,"size"),face:C(this.fontOptions,e,"face"),mod:C(this.fontOptions,e,"mod"),vadjust:C(this.fontOptions,e,"vadjust"),strokeWidth:this.fontOptions.strokeWidth,strokeColor:this.fontOptions.strokeColor};(t||A)&&("normal"===e&&!0===this.fontOptions.chooser&&this.elementOptions.labelHighlightBold?I.mod="bold":"function"==typeof this.fontOptions.chooser&&this.fontOptions.chooser(I,this.elementOptions.id,t,A));var i="";return void 0!==I.mod&&""!==I.mod&&(i+=I.mod+" "),i+=I.size+"px "+I.face,g.font=i.replace(/"/g,""),I.font=g.font,I.height=I.size,I}},{key:"differentState",value:function(g,t){return g!==this.selectedState||t!==this.hoverState}},{key:"_processLabelText",value:function(g,t,A,e){return new tk(g,this,t,A).process(e)}},{key:"_processLabel",value:function(g,t,A){if(!1!==this.labelDirty||this.differentState(t,A)){var e=this._processLabelText(g,t,A,this.elementOptions.label);this.fontOptions.minWdt>0&&e.width<this.fontOptions.minWdt&&(e.width=this.fontOptions.minWdt),this.size.labelHeight=e.height,this.fontOptions.minHgt>0&&e.height<this.fontOptions.minHgt&&(e.height=this.fontOptions.minHgt),this.lines=e.lines,this.lineCount=e.lines.length,this.size.width=e.width,this.size.height=e.height,this.selectedState=t,this.hoverState=A,this.labelDirty=!1}}},{key:"visible",value:function(){return 0!==this.size.width&&0!==this.size.height&&void 0!==this.elementOptions.label&&!(this.fontOptions.size*this.body.view.scale<this.elementOptions.scaling.label.drawThreshold-1)}}],[{key:"parseFontString",value:function(g,t){if(!t||"string"!=typeof t)return!1;var A=t.split(" ");return g.size=+A[0].replace("px",""),g.face=A[1],g.color=A[2],!0}}]),g}(),Ck=function(){function g(t,A,e){Us(this,g),this.body=A,this.labelModule=e,this.setOptions(t),this.top=void 0,this.left=void 0,this.height=void 0,this.width=void 0,this.radius=void 0,this.margin=void 0,this.refreshNeeded=!0,this.boundingBox={top:0,left:0,right:0,bottom:0}}return Xs(g,[{key:"setOptions",value:function(g){this.options=g}},{key:"_setMargins",value:function(g){this.margin={},this.options.margin&&("object"==$a(this.options.margin)?(this.margin.top=this.options.margin.top,this.margin.right=this.options.margin.right,this.margin.bottom=this.options.margin.bottom,this.margin.left=this.options.margin.left):(this.margin.top=this.options.margin,this.margin.right=this.options.margin,this.margin.bottom=this.options.margin,this.margin.left=this.options.margin)),g.adjustSizes(this.margin)}},{key:"_distanceToBorder",value:function(g,t){var A=this.options.borderWidth;return g&&this.resize(g),Math.min(Math.abs(this.width/2/Math.cos(t)),Math.abs(this.height/2/Math.sin(t)))+A}},{key:"enableShadow",value:function(g,t){t.shadow&&(g.shadowColor=t.shadowColor,g.shadowBlur=t.shadowSize,g.shadowOffsetX=t.shadowX,g.shadowOffsetY=t.shadowY)}},{key:"disableShadow",value:function(g,t){t.shadow&&(g.shadowColor="rgba(0,0,0,0)",g.shadowBlur=0,g.shadowOffsetX=0,g.shadowOffsetY=0)}},{key:"enableBorderDashes",value:function(g,t){if(!1!==t.borderDashes)if(void 0!==g.setLineDash){var A=t.borderDashes;!0===A&&(A=[5,15]),g.setLineDash(A)}else console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."),this.options.shapeProperties.borderDashes=!1,t.borderDashes=!1}},{key:"disableBorderDashes",value:function(g,t){!1!==t.borderDashes&&(void 0!==g.setLineDash?g.setLineDash([0]):(console.warn("setLineDash is not supported in this browser. The dashed borders cannot be used."),this.options.shapeProperties.borderDashes=!1,t.borderDashes=!1))}},{key:"needsRefresh",value:function(g,t){return!0===this.refreshNeeded?(this.refreshNeeded=!1,!0):void 0===this.width||this.labelModule.differentState(g,t)}},{key:"initContextForDraw",value:function(g,t){var A=t.borderWidth/this.body.view.scale;g.lineWidth=Math.min(this.width,A),g.strokeStyle=t.borderColor,g.fillStyle=t.color}},{key:"performStroke",value:function(g,t){var A=t.borderWidth/this.body.view.scale;g.save(),A>0&&(this.enableBorderDashes(g,t),g.stroke(),this.disableBorderDashes(g,t)),g.restore()}},{key:"performFill",value:function(g,t){g.save(),g.fillStyle=t.color,this.enableShadow(g,t),Rc(g).call(g),this.disableShadow(g,t),g.restore(),this.performStroke(g,t)}},{key:"_addBoundingBoxMargin",value:function(g){this.boundingBox.left-=g,this.boundingBox.top-=g,this.boundingBox.bottom+=g,this.boundingBox.right+=g}},{key:"_updateBoundingBox",value:function(g,t,A,e,C){void 0!==A&&this.resize(A,e,C),this.left=g-this.width/2,this.top=t-this.height/2,this.boundingBox.left=this.left,this.boundingBox.top=this.top,this.boundingBox.bottom=this.top+this.height,this.boundingBox.right=this.left+this.width}},{key:"updateBoundingBox",value:function(g,t,A,e,C){this._updateBoundingBox(g,t,A,e,C)}},{key:"getDimensionsFromLabel",value:function(g,t,A){this.textSize=this.labelModule.getTextSize(g,t,A);var e=this.textSize.width,C=this.textSize.height;return 0===e&&(e=14,C=14),{width:e,height:C}}}]),g}();function Ik(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var ik=function(g){dm(A,g);var t=Ik(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._setMargins(C),I}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover;if(this.needsRefresh(t,A)){var e=this.getDimensionsFromLabel(g,t,A);this.width=e.width+this.margin.right+this.margin.left,this.height=e.height+this.margin.top+this.margin.bottom,this.radius=this.width/2}}},{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C),this.left=t-this.width/2,this.top=A-this.height/2,this.initContextForDraw(g,I),ze(g,this.left,this.top,this.width,this.height,I.borderRadius),this.performFill(g,I),this.updateBoundingBox(t,A,g,e,C),this.labelModule.draw(g,this.left+this.textSize.width/2+this.margin.left,this.top+this.textSize.height/2+this.margin.top,e,C)}},{key:"updateBoundingBox",value:function(g,t,A,e,C){this._updateBoundingBox(g,t,A,e,C);var I=this.options.shapeProperties.borderRadius;this._addBoundingBoxMargin(I)}},{key:"distanceToBorder",value:function(g,t){g&&this.resize(g);var A=this.options.borderWidth;return Math.min(Math.abs(this.width/2/Math.cos(t)),Math.abs(this.height/2/Math.sin(t)))+A}}]),A}(Ck);function ok(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var nk=function(g){dm(A,g);var t=ok(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C)).labelOffset=0,I.selected=!1,I}return Xs(A,[{key:"setOptions",value:function(g,t,A){this.options=g,void 0===t&&void 0===A||this.setImages(t,A)}},{key:"setImages",value:function(g,t){t&&this.selected?(this.imageObj=t,this.imageObjAlt=g):(this.imageObj=g,this.imageObjAlt=t)}},{key:"switchImages",value:function(g){var t=g&&!this.selected||!g&&this.selected;if(this.selected=g,void 0!==this.imageObjAlt&&t){var A=this.imageObj;this.imageObj=this.imageObjAlt,this.imageObjAlt=A}}},{key:"_getImagePadding",value:function(){var g={top:0,right:0,bottom:0,left:0};if(this.options.imagePadding){var t=this.options.imagePadding;"object"==$a(t)?(g.top=t.top,g.right=t.right,g.bottom=t.bottom,g.left=t.left):(g.top=t,g.right=t,g.bottom=t,g.left=t)}return g}},{key:"_resizeImage",value:function(){var g,t;if(!1===this.options.shapeProperties.useImageSize){var A=1,e=1;this.imageObj.width&&this.imageObj.height&&(this.imageObj.width>this.imageObj.height?A=this.imageObj.width/this.imageObj.height:e=this.imageObj.height/this.imageObj.width),g=2*this.options.size*A,t=2*this.options.size*e}else{var C=this._getImagePadding();g=this.imageObj.width+C.left+C.right,t=this.imageObj.height+C.top+C.bottom}this.width=g,this.height=t,this.radius=.5*this.width}},{key:"_drawRawCircle",value:function(g,t,A,e){this.initContextForDraw(g,e),Be(g,t,A,e.size),this.performFill(g,e)}},{key:"_drawImageAtPosition",value:function(g,t){if(0!=this.imageObj.width){g.globalAlpha=void 0!==t.opacity?t.opacity:1,this.enableShadow(g,t);var A=1;!0===this.options.shapeProperties.interpolation&&(A=this.imageObj.width/this.width/this.body.view.scale);var e=this._getImagePadding(),C=this.left+e.left,I=this.top+e.top,i=this.width-e.left-e.right,o=this.height-e.top-e.bottom;this.imageObj.drawImageAtPosition(g,A,C,I,i,o),this.disableShadow(g,t)}}},{key:"_drawImageLabel",value:function(g,t,A,e,C){var I=0;if(void 0!==this.height){I=.5*this.height;var i=this.labelModule.getTextSize(g,e,C);i.lineCount>=1&&(I+=i.height/2)}var o=A+I;this.options.label&&(this.labelOffset=I),this.labelModule.draw(g,t,o,e,C,"hanging")}}]),A}(Ck);function rk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var sk=function(g){dm(A,g);var t=rk(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._setMargins(C),I}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover;if(this.needsRefresh(t,A)){var e=this.getDimensionsFromLabel(g,t,A),C=Math.max(e.width+this.margin.right+this.margin.left,e.height+this.margin.top+this.margin.bottom);this.options.size=C/2,this.width=C,this.height=C,this.radius=this.width/2}}},{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C),this.left=t-this.width/2,this.top=A-this.height/2,this._drawRawCircle(g,t,A,I),this.updateBoundingBox(t,A),this.labelModule.draw(g,this.left+this.textSize.width/2+this.margin.left,A,e,C)}},{key:"updateBoundingBox",value:function(g,t){this.boundingBox.top=t-this.options.size,this.boundingBox.left=g-this.options.size,this.boundingBox.right=g+this.options.size,this.boundingBox.bottom=t+this.options.size}},{key:"distanceToBorder",value:function(g){return g&&this.resize(g),.5*this.width}}]),A}(nk);function ak(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var dk=function(g){dm(A,g);var t=ak(A);function A(g,e,C,I,i){var o;return Us(this,A),(o=t.call(this,g,e,C)).setImages(I,i),o}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover,e=void 0===this.imageObj.src||void 0===this.imageObj.width||void 0===this.imageObj.height;if(e){var C=2*this.options.size;return this.width=C,this.height=C,void(this.radius=.5*this.width)}this.needsRefresh(t,A)&&this._resizeImage()}},{key:"draw",value:function(g,t,A,e,C,I){this.switchImages(e),this.resize();var i=t,o=A;"top-left"===this.options.shapeProperties.coordinateOrigin?(this.left=t,this.top=A,i+=this.width/2,o+=this.height/2):(this.left=t-this.width/2,this.top=A-this.height/2),this._drawRawCircle(g,i,o,I),g.save(),g.clip(),this._drawImageAtPosition(g,I),g.restore(),this._drawImageLabel(g,i,o,e,C),this.updateBoundingBox(t,A)}},{key:"updateBoundingBox",value:function(g,t){"top-left"===this.options.shapeProperties.coordinateOrigin?(this.boundingBox.top=t,this.boundingBox.left=g,this.boundingBox.right=g+2*this.options.size,this.boundingBox.bottom=t+2*this.options.size):(this.boundingBox.top=t-this.options.size,this.boundingBox.left=g-this.options.size,this.boundingBox.right=g+this.options.size,this.boundingBox.bottom=t+this.options.size),this.boundingBox.left=Math.min(this.boundingBox.left,this.labelModule.size.left),this.boundingBox.right=Math.max(this.boundingBox.right,this.labelModule.size.left+this.labelModule.size.width),this.boundingBox.bottom=Math.max(this.boundingBox.bottom,this.boundingBox.bottom+this.labelOffset)}},{key:"distanceToBorder",value:function(g){return g&&this.resize(g),.5*this.width}}]),A}(nk);function hk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var lk=function(g){dm(A,g);var t=hk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover,e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{size:this.options.size};if(this.needsRefresh(t,A)){var C,I;this.labelModule.getTextSize(g,t,A);var i=2*e.size;this.width=null!==(C=this.customSizeWidth)&&void 0!==C?C:i,this.height=null!==(I=this.customSizeHeight)&&void 0!==I?I:i,this.radius=.5*this.width}}},{key:"_drawShape",value:function(g,t,A,e,C,I,i,o){var n,r=this;return this.resize(g,I,i,o),this.left=e-this.width/2,this.top=C-this.height/2,this.initContextForDraw(g,o),(n=t,Object.prototype.hasOwnProperty.call(Ge,n)?Ge[n]:function(g){for(var t=arguments.length,A=new Array(t>1?t-1:0),e=1;e<t;e++)A[e-1]=arguments[e];CanvasRenderingContext2D.prototype[n].call(g,A)})(g,e,C,o.size),this.performFill(g,o),void 0!==this.options.icon&&void 0!==this.options.icon.code&&(g.font=(I?"bold ":"")+this.height/2+"px "+(this.options.icon.face||"FontAwesome"),g.fillStyle=this.options.icon.color||"black",g.textAlign="center",g.textBaseline="middle",g.fillText(this.options.icon.code,e,C)),{drawExternalLabel:function(){if(void 0!==r.options.label){r.labelModule.calculateLabelSize(g,I,i,e,C,"hanging");var t=C+.5*r.height+.5*r.labelModule.size.height;r.labelModule.draw(g,e,t,I,i,"hanging")}r.updateBoundingBox(e,C)}}}},{key:"updateBoundingBox",value:function(g,t){this.boundingBox.top=t-this.options.size,this.boundingBox.left=g-this.options.size,this.boundingBox.right=g+this.options.size,this.boundingBox.bottom=t+this.options.size,void 0!==this.options.label&&this.labelModule.size.width>0&&(this.boundingBox.left=Math.min(this.boundingBox.left,this.labelModule.size.left),this.boundingBox.right=Math.max(this.boundingBox.right,this.labelModule.size.left+this.labelModule.size.width),this.boundingBox.bottom=Math.max(this.boundingBox.bottom,this.boundingBox.bottom+this.labelModule.size.height))}}]),A}(Ck);function ck(g,t){var A=yd(g);if(rs){var e=rs(g);t&&(e=Uh(e).call(e,(function(t){return ys(g,t).enumerable}))),A.push.apply(A,e)}return A}function uk(g){for(var t=1;t<arguments.length;t++){var A,e,C=null!=arguments[t]?arguments[t]:{};t%2?Sd(A=ck(Object(C),!0)).call(A,(function(t){Js(g,t,C[t])})):Rs?Fs(g,Rs(C)):Sd(e=ck(Object(C))).call(e,(function(t){_s(g,t,ys(C,t))}))}return g}function pk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var fk=function(g){dm(A,g);var t=pk(A);function A(g,e,C,I){var i;return Us(this,A),(i=t.call(this,g,e,C,I)).ctxRenderer=I,i}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C,I),this.left=t-this.width/2,this.top=A-this.height/2,g.save();var i=this.ctxRenderer({ctx:g,id:this.options.id,x:t,y:A,state:{selected:e,hover:C},style:uk({},I),label:this.options.label});if(null!=i.drawNode&&i.drawNode(),g.restore(),i.drawExternalLabel){var o=i.drawExternalLabel;i.drawExternalLabel=function(){g.save(),o(),g.restore()}}return i.nodeDimensions&&(this.customSizeWidth=i.nodeDimensions.width,this.customSizeHeight=i.nodeDimensions.height),i}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function vk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var yk=function(g){dm(A,g);var t=vk(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._setMargins(C),I}return Xs(A,[{key:"resize",value:function(g,t,A){if(this.needsRefresh(t,A)){var e=this.getDimensionsFromLabel(g,t,A).width+this.margin.right+this.margin.left;this.width=e,this.height=e,this.radius=this.width/2}}},{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C),this.left=t-this.width/2,this.top=A-this.height/2,this.initContextForDraw(g,I),Se(g,t-this.width/2,A-this.height/2,this.width,this.height),this.performFill(g,I),this.updateBoundingBox(t,A,g,e,C),this.labelModule.draw(g,this.left+this.textSize.width/2+this.margin.left,this.top+this.textSize.height/2+this.margin.top,e,C)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(Ck);function mk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var bk=function(g){dm(A,g);var t=mk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"diamond",4,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function wk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var kk=function(g){dm(A,g);var t=wk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"circle",2,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g){return g&&this.resize(g),this.options.size}}]),A}(lk);function xk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Ek=function(g){dm(A,g);var t=xk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover;if(this.needsRefresh(t,A)){var e=this.getDimensionsFromLabel(g,t,A);this.height=2*e.height,this.width=e.width+e.height,this.radius=.5*this.width}}},{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C),this.left=t-.5*this.width,this.top=A-.5*this.height,this.initContextForDraw(g,I),Ze(g,this.left,this.top,this.width,this.height),this.performFill(g,I),this.updateBoundingBox(t,A,g,e,C),this.labelModule.draw(g,t,A,e,C)}},{key:"distanceToBorder",value:function(g,t){g&&this.resize(g);var A=.5*this.width,e=.5*this.height,C=Math.sin(t)*A,I=Math.cos(t)*e;return A*e/Math.sqrt(C*C+I*I)}}]),A}(Ck);function Ok(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Tk=function(g){dm(A,g);var t=Ok(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._setMargins(C),I}return Xs(A,[{key:"resize",value:function(g,t,A){this.needsRefresh(t,A)&&(this.iconSize={width:Number(this.options.icon.size),height:Number(this.options.icon.size)},this.width=this.iconSize.width+this.margin.right+this.margin.left,this.height=this.iconSize.height+this.margin.top+this.margin.bottom,this.radius=.5*this.width)}},{key:"draw",value:function(g,t,A,e,C,I){var i=this;return this.resize(g,e,C),this.options.icon.size=this.options.icon.size||50,this.left=t-this.width/2,this.top=A-this.height/2,this._icon(g,t,A,e,C,I),{drawExternalLabel:function(){if(void 0!==i.options.label){i.labelModule.draw(g,i.left+i.iconSize.width/2+i.margin.left,A+i.height/2+5,e)}i.updateBoundingBox(t,A)}}}},{key:"updateBoundingBox",value:function(g,t){if(this.boundingBox.top=t-.5*this.options.icon.size,this.boundingBox.left=g-.5*this.options.icon.size,this.boundingBox.right=g+.5*this.options.icon.size,this.boundingBox.bottom=t+.5*this.options.icon.size,void 0!==this.options.label&&this.labelModule.size.width>0){this.boundingBox.left=Math.min(this.boundingBox.left,this.labelModule.size.left),this.boundingBox.right=Math.max(this.boundingBox.right,this.labelModule.size.left+this.labelModule.size.width),this.boundingBox.bottom=Math.max(this.boundingBox.bottom,this.boundingBox.bottom+this.labelModule.size.height+5)}}},{key:"_icon",value:function(g,t,A,e,C,I){var i=Number(this.options.icon.size);void 0!==this.options.icon.code?(g.font=[null!=this.options.icon.weight?this.options.icon.weight:e?"bold":"",(null!=this.options.icon.weight&&e?5:0)+i+"px",this.options.icon.face].join(" "),g.fillStyle=this.options.icon.color||"black",g.textAlign="center",g.textBaseline="middle",this.enableShadow(g,I),g.fillText(this.options.icon.code,t,A),this.disableShadow(g,I)):console.error("When using the icon shape, you need to define the code in the icon options object. This can be done per node or globally.")}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(Ck);function Dk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Nk=function(g){dm(A,g);var t=Dk(A);function A(g,e,C,I,i){var o;return Us(this,A),(o=t.call(this,g,e,C)).setImages(I,i),o}return Xs(A,[{key:"resize",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.selected,A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this.hover,e=void 0===this.imageObj.src||void 0===this.imageObj.width||void 0===this.imageObj.height;if(e){var C=2*this.options.size;return this.width=C,void(this.height=C)}this.needsRefresh(t,A)&&this._resizeImage()}},{key:"draw",value:function(g,t,A,e,C,I){g.save(),this.switchImages(e),this.resize();var i=t,o=A;if("top-left"===this.options.shapeProperties.coordinateOrigin?(this.left=t,this.top=A,i+=this.width/2,o+=this.height/2):(this.left=t-this.width/2,this.top=A-this.height/2),!0===this.options.shapeProperties.useBorderWithImage){var n=this.options.borderWidth,r=this.options.borderWidthSelected||2*this.options.borderWidth,s=(e?r:n)/this.body.view.scale;g.lineWidth=Math.min(this.width,s),g.beginPath();var a=e?this.options.color.highlight.border:C?this.options.color.hover.border:this.options.color.border,d=e?this.options.color.highlight.background:C?this.options.color.hover.background:this.options.color.background;void 0!==I.opacity&&(a=Xp(a,I.opacity),d=Xp(d,I.opacity)),g.strokeStyle=a,g.fillStyle=d,g.rect(this.left-.5*g.lineWidth,this.top-.5*g.lineWidth,this.width+g.lineWidth,this.height+g.lineWidth),Rc(g).call(g),this.performStroke(g,I),g.closePath()}this._drawImageAtPosition(g,I),this._drawImageLabel(g,i,o,e,C),this.updateBoundingBox(t,A),g.restore()}},{key:"updateBoundingBox",value:function(g,t){this.resize(),"top-left"===this.options.shapeProperties.coordinateOrigin?(this.left=g,this.top=t):(this.left=g-this.width/2,this.top=t-this.height/2),this.boundingBox.left=this.left,this.boundingBox.top=this.top,this.boundingBox.bottom=this.top+this.height,this.boundingBox.right=this.left+this.width,void 0!==this.options.label&&this.labelModule.size.width>0&&(this.boundingBox.left=Math.min(this.boundingBox.left,this.labelModule.size.left),this.boundingBox.right=Math.max(this.boundingBox.right,this.labelModule.size.left+this.labelModule.size.width),this.boundingBox.bottom=Math.max(this.boundingBox.bottom,this.boundingBox.bottom+this.labelOffset))}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(nk);function Rk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Pk=function(g){dm(A,g);var t=Rk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"square",2,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function Mk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Bk=function(g){dm(A,g);var t=Mk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"hexagon",4,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function zk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Zk=function(g){dm(A,g);var t=zk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"star",4,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function Sk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Fk=function(g){dm(A,g);var t=Sk(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._setMargins(C),I}return Xs(A,[{key:"resize",value:function(g,t,A){this.needsRefresh(t,A)&&(this.textSize=this.labelModule.getTextSize(g,t,A),this.width=this.textSize.width+this.margin.right+this.margin.left,this.height=this.textSize.height+this.margin.top+this.margin.bottom,this.radius=.5*this.width)}},{key:"draw",value:function(g,t,A,e,C,I){this.resize(g,e,C),this.left=t-this.width/2,this.top=A-this.height/2,this.enableShadow(g,I),this.labelModule.draw(g,this.left+this.textSize.width/2+this.margin.left,this.top+this.textSize.height/2+this.margin.top,e,C),this.disableShadow(g,I),this.updateBoundingBox(t,A,g,e,C)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(Ck);function Gk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Lk=function(g){dm(A,g);var t=Gk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"triangle",3,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function jk(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Vk=function(g){dm(A,g);var t=jk(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"draw",value:function(g,t,A,e,C,I){return this._drawShape(g,"triangleDown",3,t,A,e,C,I)}},{key:"distanceToBorder",value:function(g,t){return this._distanceToBorder(g,t)}}]),A}(lk);function Yk(g,t){var A=yd(g);if(rs){var e=rs(g);t&&(e=Uh(e).call(e,(function(t){return ys(g,t).enumerable}))),A.push.apply(A,e)}return A}function Wk(g){for(var t=1;t<arguments.length;t++){var A,e,C=null!=arguments[t]?arguments[t]:{};t%2?Sd(A=Yk(Object(C),!0)).call(A,(function(t){Js(g,t,C[t])})):Rs?Fs(g,Rs(C)):Sd(e=Yk(Object(C))).call(e,(function(t){_s(g,t,ys(C,t))}))}return g}var Qk=function(){function g(t,A,e,C,I,i){Us(this,g),this.options=If(I),this.globalOptions=I,this.defaultOptions=i,this.body=A,this.edges=[],this.id=void 0,this.imagelist=e,this.grouplist=C,this.x=void 0,this.y=void 0,this.baseSize=this.options.size,this.baseFontSize=this.options.font.size,this.predefinedPosition=!1,this.selected=!1,this.hover=!1,this.labelModule=new ek(this.body,this.options,!1),this.setOptions(t)}return Xs(g,[{key:"attachEdge",value:function(g){var t;-1===zl(t=this.edges).call(t,g)&&this.edges.push(g)}},{key:"detachEdge",value:function(g){var t,A,e=zl(t=this.edges).call(t,g);-1!=e&&hh(A=this.edges).call(A,e,1)}},{key:"setOptions",value:function(t){var A=this.options.shape;if(t){if(void 0!==t.color&&(this._localColor=t.color),void 0!==t.id&&(this.id=t.id),void 0===this.id)throw new Error("Node must have an id");g.checkMass(t,this.id),void 0!==t.x&&(null===t.x?(this.x=void 0,this.predefinedPosition=!1):(this.x=wl(t.x),this.predefinedPosition=!0)),void 0!==t.y&&(null===t.y?(this.y=void 0,this.predefinedPosition=!1):(this.y=wl(t.y),this.predefinedPosition=!0)),void 0!==t.size&&(this.baseSize=t.size),void 0!==t.value&&(t.value=jw(t.value)),g.parseOptions(this.options,t,!0,this.globalOptions,this.grouplist);var e=[t,this.options,this.defaultOptions];return this.chooser=Hw("node",e),this._load_images(),this.updateLabelModule(t),void 0!==t.opacity&&g.checkOpacity(t.opacity)&&(this.options.opacity=t.opacity),this.updateShape(A),void 0!==t.hidden||void 0!==t.physics}}},{key:"_load_images",value:function(){if(("circularImage"===this.options.shape||"image"===this.options.shape)&&void 0===this.options.image)throw new Error("Option image must be defined for node type '"+this.options.shape+"'");if(void 0!==this.options.image){if(void 0===this.imagelist)throw new Error("Internal Error: No images provided");if("string"==typeof this.options.image)this.imageObj=this.imagelist.load(this.options.image,this.options.brokenImage,this.id);else{if(void 0===this.options.image.unselected)throw new Error("No unselected image provided");this.imageObj=this.imagelist.load(this.options.image.unselected,this.options.brokenImage,this.id),void 0!==this.options.image.selected?this.imageObjAlt=this.imagelist.load(this.options.image.selected,this.options.brokenImage,this.id):this.imageObjAlt=void 0}}}},{key:"getFormattingValues",value:function(){var g={color:this.options.color.background,opacity:this.options.opacity,borderWidth:this.options.borderWidth,borderColor:this.options.color.border,size:this.options.size,borderDashes:this.options.shapeProperties.borderDashes,borderRadius:this.options.shapeProperties.borderRadius,shadow:this.options.shadow.enabled,shadowColor:this.options.shadow.color,shadowSize:this.options.shadow.size,shadowX:this.options.shadow.x,shadowY:this.options.shadow.y};if(this.selected||this.hover?!0===this.chooser?this.selected?(null!=this.options.borderWidthSelected?g.borderWidth=this.options.borderWidthSelected:g.borderWidth*=2,g.color=this.options.color.highlight.background,g.borderColor=this.options.color.highlight.border,g.shadow=this.options.shadow.enabled):this.hover&&(g.color=this.options.color.hover.background,g.borderColor=this.options.color.hover.border,g.shadow=this.options.shadow.enabled):"function"==typeof this.chooser&&(this.chooser(g,this.options.id,this.selected,this.hover),!1===g.shadow&&(g.shadowColor===this.options.shadow.color&&g.shadowSize===this.options.shadow.size&&g.shadowX===this.options.shadow.x&&g.shadowY===this.options.shadow.y||(g.shadow=!0))):g.shadow=this.options.shadow.enabled,void 0!==this.options.opacity){var t=this.options.opacity;g.borderColor=Xp(g.borderColor,t),g.color=Xp(g.color,t),g.shadowColor=Xp(g.shadowColor,t)}return g}},{key:"updateLabelModule",value:function(t){void 0!==this.options.label&&null!==this.options.label||(this.options.label=""),g.updateGroupOptions(this.options,Wk(Wk({},t),{},{color:t&&t.color||this._localColor||void 0}),this.grouplist);var A=this.grouplist.get(this.options.group,!1),e=[t,this.options,A,this.globalOptions,this.defaultOptions];this.labelModule.update(this.options,e),void 0!==this.labelModule.baseSize&&(this.baseFontSize=this.labelModule.baseSize)}},{key:"updateShape",value:function(g){if(g===this.options.shape&&this.shape)this.shape.setOptions(this.options,this.imageObj,this.imageObjAlt);else switch(this.options.shape){case"box":this.shape=new ik(this.options,this.body,this.labelModule);break;case"circle":this.shape=new sk(this.options,this.body,this.labelModule);break;case"circularImage":this.shape=new dk(this.options,this.body,this.labelModule,this.imageObj,this.imageObjAlt);break;case"custom":this.shape=new fk(this.options,this.body,this.labelModule,this.options.ctxRenderer);break;case"database":this.shape=new yk(this.options,this.body,this.labelModule);break;case"diamond":this.shape=new bk(this.options,this.body,this.labelModule);break;case"dot":this.shape=new kk(this.options,this.body,this.labelModule);break;case"ellipse":default:this.shape=new Ek(this.options,this.body,this.labelModule);break;case"icon":this.shape=new Tk(this.options,this.body,this.labelModule);break;case"image":this.shape=new Nk(this.options,this.body,this.labelModule,this.imageObj,this.imageObjAlt);break;case"square":this.shape=new Pk(this.options,this.body,this.labelModule);break;case"hexagon":this.shape=new Bk(this.options,this.body,this.labelModule);break;case"star":this.shape=new Zk(this.options,this.body,this.labelModule);break;case"text":this.shape=new Fk(this.options,this.body,this.labelModule);break;case"triangle":this.shape=new Lk(this.options,this.body,this.labelModule);break;case"triangleDown":this.shape=new Vk(this.options,this.body,this.labelModule)}this.needsRefresh()}},{key:"select",value:function(){this.selected=!0,this.needsRefresh()}},{key:"unselect",value:function(){this.selected=!1,this.needsRefresh()}},{key:"needsRefresh",value:function(){this.shape.refreshNeeded=!0}},{key:"getTitle",value:function(){return this.options.title}},{key:"distanceToBorder",value:function(g,t){return this.shape.distanceToBorder(g,t)}},{key:"isFixed",value:function(){return this.options.fixed.x&&this.options.fixed.y}},{key:"isSelected",value:function(){return this.selected}},{key:"getValue",value:function(){return this.options.value}},{key:"getLabelSize",value:function(){return this.labelModule.size()}},{key:"setValueRange",value:function(g,t,A){if(void 0!==this.options.value){var e=this.options.scaling.customScalingFunction(g,t,A,this.options.value),C=this.options.scaling.max-this.options.scaling.min;if(!0===this.options.scaling.label.enabled){var I=this.options.scaling.label.max-this.options.scaling.label.min;this.options.font.size=this.options.scaling.label.min+e*I}this.options.size=this.options.scaling.min+e*C}else this.options.size=this.baseSize,this.options.font.size=this.baseFontSize;this.updateLabelModule()}},{key:"draw",value:function(g){var t=this.getFormattingValues();return this.shape.draw(g,this.x,this.y,this.selected,this.hover,t)||{}}},{key:"updateBoundingBox",value:function(g){this.shape.updateBoundingBox(this.x,this.y,g)}},{key:"resize",value:function(g){var t=this.getFormattingValues();this.shape.resize(g,this.selected,this.hover,t)}},{key:"getItemsOnPoint",value:function(g){var t=[];return this.labelModule.visible()&&Kw(this.labelModule.getSize(),g)&&t.push({nodeId:this.id,labelId:0}),Kw(this.shape.boundingBox,g)&&t.push({nodeId:this.id}),t}},{key:"isOverlappingWith",value:function(g){return this.shape.left<g.right&&this.shape.left+this.shape.width>g.left&&this.shape.top<g.bottom&&this.shape.top+this.shape.height>g.top}},{key:"isBoundingBoxOverlappingWith",value:function(g){return this.shape.boundingBox.left<g.right&&this.shape.boundingBox.right>g.left&&this.shape.boundingBox.top<g.bottom&&this.shape.boundingBox.bottom>g.top}}],[{key:"checkOpacity",value:function(g){return 0<=g&&g<=1}},{key:"checkCoordinateOrigin",value:function(g){return void 0===g||"center"===g||"top-left"===g}},{key:"updateGroupOptions",value:function(t,A,e){var C;if(void 0!==e){var I=t.group;if(void 0!==A&&void 0!==A.group&&I!==A.group)throw new Error("updateGroupOptions: group values in options don't match.");if("number"==typeof I||"string"==typeof I&&""!=I){var i=e.get(I);void 0!==i.opacity&&void 0===A.opacity&&(g.checkOpacity(i.opacity)||(console.error("Invalid option for node opacity. Value must be between 0 and 1, found: "+i.opacity),i.opacity=void 0));var o=Uh(C=Uw(A)).call(C,(function(g){return null!=A[g]}));o.push("font"),Yp(o,t,i),t.color=qp(t.color)}}}},{key:"parseOptions",value:function(t,A){var e=arguments.length>2&&void 0!==arguments[2]&&arguments[2],C=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},I=arguments.length>4?arguments[4]:void 0,i=["color","fixed","shadow"];if(Yp(i,t,A,e),g.checkMass(A),void 0!==t.opacity&&(g.checkOpacity(t.opacity)||(console.error("Invalid option for node opacity. Value must be between 0 and 1, found: "+t.opacity),t.opacity=void 0)),void 0!==A.opacity&&(g.checkOpacity(A.opacity)||(console.error("Invalid option for node opacity. Value must be between 0 and 1, found: "+A.opacity),A.opacity=void 0)),A.shapeProperties&&!g.checkCoordinateOrigin(A.shapeProperties.coordinateOrigin)&&console.error("Invalid option for node coordinateOrigin, found: "+A.shapeProperties.coordinateOrigin),of(t,A,"shadow",C),void 0!==A.color&&null!==A.color){var o=qp(A.color);jp(t.color,o)}else!0===e&&null===A.color&&(t.color=If(C.color));void 0!==A.fixed&&null!==A.fixed&&("boolean"==typeof A.fixed?(t.fixed.x=A.fixed,t.fixed.y=A.fixed):(void 0!==A.fixed.x&&"boolean"==typeof A.fixed.x&&(t.fixed.x=A.fixed.x),void 0!==A.fixed.y&&"boolean"==typeof A.fixed.y&&(t.fixed.y=A.fixed.y))),!0===e&&null===A.font&&(t.font=If(C.font)),g.updateGroupOptions(t,A,I),void 0!==A.scaling&&of(t.scaling,A.scaling,"label",C.scaling)}},{key:"checkMass",value:function(g,t){if(void 0!==g.mass&&g.mass<=0){var A="";void 0!==t&&(A=" in node id: "+t),console.error("%cNegative or zero mass disallowed"+A+", setting mass to 1.",wf),g.mass=1}}}]),g}();function _k(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return Uk(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return Uk(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function Uk(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}var Hk=function(){function g(t,A,e,C){var I,i=this;if(Us(this,g),this.body=t,this.images=A,this.groups=e,this.layoutEngine=C,this.body.functions.createNode=Me(I=this.create).call(I,this),this.nodesListeners={add:function(g,t){i.add(t.items)},update:function(g,t){i.update(t.items,t.data,t.oldData)},remove:function(g,t){i.remove(t.items)}},this.defaultOptions={borderWidth:1,borderWidthSelected:void 0,brokenImage:void 0,color:{border:"#2B7CE9",background:"#97C2FC",highlight:{border:"#2B7CE9",background:"#D2E5FF"},hover:{border:"#2B7CE9",background:"#D2E5FF"}},opacity:void 0,fixed:{x:!1,y:!1},font:{color:"#343434",size:14,face:"arial",background:"none",strokeWidth:0,strokeColor:"#ffffff",align:"center",vadjust:0,multi:!1,bold:{mod:"bold"},boldital:{mod:"bold italic"},ital:{mod:"italic"},mono:{mod:"",size:15,face:"monospace",vadjust:2}},group:void 0,hidden:!1,icon:{face:"FontAwesome",code:void 0,size:50,color:"#2B7CE9"},image:void 0,imagePadding:{top:0,right:0,bottom:0,left:0},label:void 0,labelHighlightBold:!0,level:void 0,margin:{top:5,right:5,bottom:5,left:5},mass:1,physics:!0,scaling:{min:10,max:30,label:{enabled:!1,min:14,max:30,maxVisible:30,drawThreshold:5},customScalingFunction:function(g,t,A,e){if(t===g)return.5;var C=1/(t-g);return Math.max(0,(e-g)*C)}},shadow:{enabled:!1,color:"rgba(0,0,0,0.5)",size:10,x:5,y:5},shape:"ellipse",shapeProperties:{borderDashes:!1,borderRadius:6,interpolation:!0,useImageSize:!1,useBorderWithImage:!1,coordinateOrigin:"center"},size:25,title:void 0,value:void 0,x:void 0,y:void 0},this.defaultOptions.mass<=0)throw"Internal error: mass in defaultOptions of NodesHandler may not be zero or negative";this.options=If(this.defaultOptions),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g,t,A=this;this.body.emitter.on("refreshNodes",Me(g=this.refresh).call(g,this)),this.body.emitter.on("refresh",Me(t=this.refresh).call(t,this)),this.body.emitter.on("destroy",(function(){Hp(A.nodesListeners,(function(g,t){A.body.data.nodes&&A.body.data.nodes.off(t,g)})),delete A.body.functions.createNode,delete A.nodesListeners.add,delete A.nodesListeners.update,delete A.nodesListeners.remove,delete A.nodesListeners}))}},{key:"setOptions",value:function(g){if(void 0!==g){if(Qk.parseOptions(this.options,g),void 0!==g.opacity&&(My(g.opacity)||!Zy(g.opacity)||g.opacity<0||g.opacity>1?console.error("Invalid option for node opacity. Value must be between 0 and 1, found: "+g.opacity):this.options.opacity=g.opacity),void 0!==g.shape)for(var t in this.body.nodes)Object.prototype.hasOwnProperty.call(this.body.nodes,t)&&this.body.nodes[t].updateShape();if(void 0!==g.font||void 0!==g.widthConstraint||void 0!==g.heightConstraint)for(var A=0,e=yd(this.body.nodes);A<e.length;A++){var C=e[A];this.body.nodes[C].updateLabelModule(),this.body.nodes[C].needsRefresh()}if(void 0!==g.size)for(var I in this.body.nodes)Object.prototype.hasOwnProperty.call(this.body.nodes,I)&&this.body.nodes[I].needsRefresh();void 0===g.hidden&&void 0===g.physics||this.body.emitter.emit("_dataChanged")}}},{key:"setData",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],A=this.body.data.nodes;if(Nw("id",g))this.body.data.nodes=g;else if(sd(g))this.body.data.nodes=new Ow,this.body.data.nodes.add(g);else{if(g)throw new TypeError("Array or DataSet expected");this.body.data.nodes=new Ow}if(A&&Hp(this.nodesListeners,(function(g,t){A.off(t,g)})),this.body.nodes={},this.body.data.nodes){var e=this;Hp(this.nodesListeners,(function(g,t){e.body.data.nodes.on(t,g)}));var C=this.body.data.nodes.getIds();this.add(C,!0)}!1===t&&this.body.emitter.emit("_dataChanged")}},{key:"add",value:function(g){for(var t,A=arguments.length>1&&void 0!==arguments[1]&&arguments[1],e=[],C=0;C<g.length;C++){t=g[C];var I=this.body.data.nodes.get(t),i=this.create(I);e.push(i),this.body.nodes[t]=i}this.layoutEngine.positionInitially(e),!1===A&&this.body.emitter.emit("_dataChanged")}},{key:"update",value:function(g,t,A){for(var e=this.body.nodes,C=!1,I=0;I<g.length;I++){var i=g[I],o=e[i],n=t[I];void 0!==o?o.setOptions(n)&&(C=!0):(C=!0,o=this.create(n),e[i]=o)}C||void 0===A||(C=Yy(t).call(t,(function(g,t){var e=A[t];return e&&e.level!==g.level}))),!0===C?this.body.emitter.emit("_dataChanged"):this.body.emitter.emit("_dataUpdated")}},{key:"remove",value:function(g){for(var t=this.body.nodes,A=0;A<g.length;A++){delete t[g[A]]}this.body.emitter.emit("_dataChanged")}},{key:"create",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Qk;return new t(g,this.body,this.images,this.groups,this.options,this.defaultOptions)}},{key:"refresh",value:function(){var g=this,t=arguments.length>0&&void 0!==arguments[0]&&arguments[0];Hp(this.body.nodes,(function(A,e){var C=g.body.data.nodes.get(e);void 0!==C&&(!0===t&&A.setOptions({x:null,y:null}),A.setOptions({fixed:!1}),A.setOptions(C))}))}},{key:"getPositions",value:function(g){var t={};if(void 0!==g){if(!0===sd(g)){for(var A=0;A<g.length;A++)if(void 0!==this.body.nodes[g[A]]){var e=this.body.nodes[g[A]];t[g[A]]={x:Math.round(e.x),y:Math.round(e.y)}}}else if(void 0!==this.body.nodes[g]){var C=this.body.nodes[g];t[g]={x:Math.round(C.x),y:Math.round(C.y)}}}else for(var I=0;I<this.body.nodeIndices.length;I++){var i=this.body.nodes[this.body.nodeIndices[I]];t[this.body.nodeIndices[I]]={x:Math.round(i.x),y:Math.round(i.y)}}return t}},{key:"getPosition",value:function(g){if(null==g)throw new TypeError("No id was specified for getPosition method.");if(null==this.body.nodes[g])throw new ReferenceError("NodeId provided for getPosition does not exist. Provided: ".concat(g));return{x:Math.round(this.body.nodes[g].x),y:Math.round(this.body.nodes[g].y)}}},{key:"storePositions",value:function(){var g,t=[],A=this.body.data.nodes.getDataSet(),e=_k(A.get());try{for(e.s();!(g=e.n()).done;){var C=g.value,I=C.id,i=this.body.nodes[I],o=Math.round(i.x),n=Math.round(i.y);C.x===o&&C.y===n||t.push({id:I,x:o,y:n})}}catch(g){e.e(g)}finally{e.f()}A.update(t)}},{key:"getBoundingBox",value:function(g){if(void 0!==this.body.nodes[g])return this.body.nodes[g].shape.boundingBox}},{key:"getConnectedNodes",value:function(g,t){var A=[];if(void 0!==this.body.nodes[g])for(var e=this.body.nodes[g],C={},I=0;I<e.edges.length;I++){var i=e.edges[I];"to"!==t&&i.toId==e.id?void 0===C[i.fromId]&&(A.push(i.fromId),C[i.fromId]=!0):"from"!==t&&i.fromId==e.id&&void 0===C[i.toId]&&(A.push(i.toId),C[i.toId]=!0)}return A}},{key:"getConnectedEdges",value:function(g){var t=[];if(void 0!==this.body.nodes[g])for(var A=this.body.nodes[g],e=0;e<A.edges.length;e++)t.push(A.edges[e].id);else console.error("NodeId provided for getConnectedEdges does not exist. Provided: ",g);return t}},{key:"moveNode",value:function(g,t,A){var e=this;void 0!==this.body.nodes[g]?(this.body.nodes[g].x=Number(t),this.body.nodes[g].y=Number(A),yc((function(){e.body.emitter.emit("startSimulation")}),0)):console.error("Node id supplied to moveNode does not exist. Provided: ",g)}}]),g}(),Kk=Vg,Xk=b,Jk=_,qk=Xt,$k=function(g){return void 0!==g&&(Kk(g,"value")||Kk(g,"writable"))},gx=f,tx=DI;bA({target:"Reflect",stat:!0},{get:function g(t,A){var e,C,I=arguments.length<3?t:arguments[2];return qk(t)===I?t[A]:(e=gx.f(t,A))?$k(e)?e.value:void 0===e.get?void 0:Xk(e.get,I):Jk(C=tx(t))?g(C,A,I):void 0}});var Ax=U.Reflect.get,ex=vs;function Cx(g,t){for(;!Object.prototype.hasOwnProperty.call(g,t)&&null!==(g=cm(g)););return g}function Ix(){return Ix="undefined"!=typeof Reflect&&Ax?Ax:function(g,t,A){var e=Cx(g,t);if(e){var C=ex(e,t);return C.get?C.get.call(arguments.length<3?g:A):C.value}},Ix.apply(this,arguments)}var ix=bA,ox=Math.hypot,nx=Math.abs,rx=Math.sqrt;ix({target:"Math",stat:!0,forced:!!ox&&ox(1/0,NaN)!==1/0},{hypot:function(g,t){for(var A,e,C=0,I=0,i=arguments.length,o=0;I<i;)o<(A=nx(arguments[I++]))?(C=C*(e=o/A)*e+1,o=A):C+=A>0?(e=A/o)*e:A;return o===1/0?1/0:o*rx(C)}});var sx=U.Math.hypot;function ax(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var dx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"transform",value:function(g,t){sd(g)||(g=[g]);for(var A=t.point.x,e=t.point.y,C=t.angle,I=t.length,i=0;i<g.length;++i){var o=g[i],n=o.x*Math.cos(C)-o.y*Math.sin(C),r=o.x*Math.sin(C)+o.y*Math.cos(C);o.x=A+I*n,o.y=e+I*r}}},{key:"drawPath",value:function(g,t){g.beginPath(),g.moveTo(t[0].x,t[0].y);for(var A=1;A<t.length;++A)g.lineTo(t[A].x,t[A].y);g.closePath()}}]),g}(),hx=function(g){dm(A,g);var t=ax(A);function A(){return Us(this,A),t.apply(this,arguments)}return Xs(A,null,[{key:"draw",value:function(g,t){if(t.image){g.save(),g.translate(t.point.x,t.point.y),g.rotate(Math.PI/2+t.angle);var A=null!=t.imageWidth?t.imageWidth:t.image.width,e=null!=t.imageHeight?t.imageHeight:t.image.height;t.image.drawImageAtPosition(g,1,-A/2,0,A,e),g.restore()}return!1}}]),A}(dx),lx=function(g){dm(A,g);var t=ax(A);function A(){return Us(this,A),t.apply(this,arguments)}return Xs(A,null,[{key:"draw",value:function(g,t){var A=[{x:0,y:0},{x:-1,y:.3},{x:-.9,y:0},{x:-1,y:-.3}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),A}(dx),cx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:-1,y:0},{x:0,y:.3},{x:-.4,y:0},{x:0,y:-.3}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),ux=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A={x:-.4,y:0};dx.transform(A,t),g.strokeStyle=g.fillStyle,g.fillStyle="rgba(0, 0, 0, 0)";var e=Math.PI,C=t.angle-e/2,I=t.angle+e/2;return g.beginPath(),g.arc(A.x,A.y,.4*t.length,C,I,!1),g.stroke(),!0}}]),g}(),px=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A={x:-.3,y:0};dx.transform(A,t),g.strokeStyle=g.fillStyle,g.fillStyle="rgba(0, 0, 0, 0)";var e=Math.PI,C=t.angle+e/2,I=t.angle+3*e/2;return g.beginPath(),g.arc(A.x,A.y,.4*t.length,C,I,!1),g.stroke(),!0}}]),g}(),fx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:.02,y:0},{x:-1,y:.3},{x:-1,y:-.3}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),vx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:0,y:.3},{x:0,y:-.3},{x:-1,y:0}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),yx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A={x:-.4,y:0};return dx.transform(A,t),Be(g,A.x,A.y,.4*t.length),!0}}]),g}(),mx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:0,y:.5},{x:0,y:-.5},{x:-.15,y:-.5},{x:-.15,y:.5}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),bx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:0,y:.3},{x:0,y:-.3},{x:-.6,y:-.3},{x:-.6,y:.3}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),wx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:0,y:0},{x:-.5,y:-.3},{x:-1,y:0},{x:-.5,y:.3}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),kx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A=[{x:-1,y:.3},{x:-.5,y:0},{x:-1,y:-.3},{x:0,y:0}];return dx.transform(A,t),dx.drawPath(g,A),!0}}]),g}(),xx=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"draw",value:function(g,t){var A;switch(t.type&&(A=t.type.toLowerCase()),A){case"image":return hx.draw(g,t);case"circle":return yx.draw(g,t);case"box":return bx.draw(g,t);case"crow":return cx.draw(g,t);case"curve":return ux.draw(g,t);case"diamond":return wx.draw(g,t);case"inv_curve":return px.draw(g,t);case"triangle":return fx.draw(g,t);case"inv_triangle":return vx.draw(g,t);case"bar":return mx.draw(g,t);case"vee":return kx.draw(g,t);default:return lx.draw(g,t)}}}]),g}();function Ex(g,t){var A=yd(g);if(rs){var e=rs(g);t&&(e=Uh(e).call(e,(function(t){return ys(g,t).enumerable}))),A.push.apply(A,e)}return A}function Ox(g){for(var t=1;t<arguments.length;t++){var A,e,C=null!=arguments[t]?arguments[t]:{};t%2?Sd(A=Ex(Object(C),!0)).call(A,(function(t){Js(g,t,C[t])})):Rs?Fs(g,Rs(C)):Sd(e=Ex(Object(C))).call(e,(function(t){_s(g,t,ys(C,t))}))}return g}var Tx=function(){function g(t,A,e){Us(this,g),this._body=A,this._labelModule=e,this.color={},this.colorDirty=!0,this.hoverWidth=1.5,this.selectionWidth=2,this.setOptions(t),this.fromPoint=this.from,this.toPoint=this.to}return Xs(g,[{key:"connect",value:function(){this.from=this._body.nodes[this.options.from],this.to=this._body.nodes[this.options.to]}},{key:"cleanup",value:function(){return!1}},{key:"setOptions",value:function(g){this.options=g,this.from=this._body.nodes[this.options.from],this.to=this._body.nodes[this.options.to],this.id=this.options.id}},{key:"drawLine",value:function(g,t,A,e){var C=arguments.length>4&&void 0!==arguments[4]?arguments[4]:this.getViaNode();g.strokeStyle=this.getColor(g,t),g.lineWidth=t.width,!1!==t.dashes?this._drawDashedLine(g,t,C):this._drawLine(g,t,C)}},{key:"_drawLine",value:function(g,t,A,e,C){if(this.from!=this.to)this._line(g,t,A,e,C);else{var I=Xa(this._getCircleData(g),3),i=I[0],o=I[1],n=I[2];this._circle(g,t,i,o,n)}}},{key:"_drawDashedLine",value:function(g,t,A,e,C){g.lineCap="round";var I=sd(t.dashes)?t.dashes:[5,5];if(void 0!==g.setLineDash){if(g.save(),g.setLineDash(I),g.lineDashOffset=0,this.from!=this.to)this._line(g,t,A);else{var i=Xa(this._getCircleData(g),3),o=i[0],n=i[1],r=i[2];this._circle(g,t,o,n,r)}g.setLineDash([0]),g.lineDashOffset=0,g.restore()}else{if(this.from!=this.to)Fe(g,this.from.x,this.from.y,this.to.x,this.to.y,I);else{var s=Xa(this._getCircleData(g),3),a=s[0],d=s[1],h=s[2];this._circle(g,t,a,d,h)}this.enableShadow(g,t),g.stroke(),this.disableShadow(g,t)}}},{key:"findBorderPosition",value:function(g,t,A){return this.from!=this.to?this._findBorderPosition(g,t,A):this._findBorderPositionCircle(g,t,A)}},{key:"findBorderPositions",value:function(g){if(this.from!=this.to)return{from:this._findBorderPosition(this.from,g),to:this._findBorderPosition(this.to,g)};var t,A=Xa(nd(t=this._getCircleData(g)).call(t,0,2),2),e=A[0],C=A[1];return{from:this._findBorderPositionCircle(this.from,g,{x:e,y:C,low:.25,high:.6,direction:-1}),to:this._findBorderPositionCircle(this.from,g,{x:e,y:C,low:.6,high:.8,direction:1})}}},{key:"_getCircleData",value:function(g){var t=this.options.selfReference.size;void 0!==g&&void 0===this.from.shape.width&&this.from.shape.resize(g);var A=Jw(g,this.options.selfReference.angle,t,this.from);return[A.x,A.y,t]}},{key:"_pointOnCircle",value:function(g,t,A,e){var C=2*e*Math.PI;return{x:g+A*Math.cos(C),y:t-A*Math.sin(C)}}},{key:"_findBorderPositionCircle",value:function(g,t,A){var e,C=A.x,I=A.y,i=A.low,o=A.high,n=A.direction,r=this.options.selfReference.size,s=.5*(i+o),a=0;!0===this.options.arrowStrikethrough&&(-1===n?a=this.options.endPointOffset.from:1===n&&(a=this.options.endPointOffset.to));var d=0;do{s=.5*(i+o),e=this._pointOnCircle(C,I,r,s);var h=Math.atan2(g.y-e.y,g.x-e.x),l=g.distanceToBorder(t,h)+a-Math.sqrt(Math.pow(e.x-g.x,2)+Math.pow(e.y-g.y,2));if(Math.abs(l)<.05)break;l>0?n>0?i=s:o=s:n>0?o=s:i=s,++d}while(i<=o&&d<10);return Ox(Ox({},e),{},{t:s})}},{key:"getLineWidth",value:function(g,t){return!0===g?Math.max(this.selectionWidth,.3/this._body.view.scale):!0===t?Math.max(this.hoverWidth,.3/this._body.view.scale):Math.max(this.options.width,.3/this._body.view.scale)}},{key:"getColor",value:function(g,t){if(!1!==t.inheritsColor){if("both"===t.inheritsColor&&this.from.id!==this.to.id){var A=g.createLinearGradient(this.from.x,this.from.y,this.to.x,this.to.y),e=this.from.options.color.highlight.border,C=this.to.options.color.highlight.border;return!1===this.from.selected&&!1===this.to.selected?(e=Xp(this.from.options.color.border,t.opacity),C=Xp(this.to.options.color.border,t.opacity)):!0===this.from.selected&&!1===this.to.selected?C=this.to.options.color.border:!1===this.from.selected&&!0===this.to.selected&&(e=this.from.options.color.border),A.addColorStop(0,e),A.addColorStop(1,C),A}return"to"===t.inheritsColor?Xp(this.to.options.color.border,t.opacity):Xp(this.from.options.color.border,t.opacity)}return Xp(t.color,t.opacity)}},{key:"_circle",value:function(g,t,A,e,C){this.enableShadow(g,t);var I=0,i=2*Math.PI;if(!this.options.selfReference.renderBehindTheNode){var o=this.options.selfReference.angle,n=this.options.selfReference.angle+Math.PI,r=this._findBorderPositionCircle(this.from,g,{x:A,y:e,low:o,high:n,direction:-1}),s=this._findBorderPositionCircle(this.from,g,{x:A,y:e,low:o,high:n,direction:1});I=Math.atan2(r.y-e,r.x-A),i=Math.atan2(s.y-e,s.x-A)}g.beginPath(),g.arc(A,e,C,I,i,!1),g.stroke(),this.disableShadow(g,t)}},{key:"getDistanceToEdge",value:function(g,t,A,e,C,I){if(this.from!=this.to)return this._getDistanceToEdge(g,t,A,e,C,I);var i=Xa(this._getCircleData(void 0),3),o=i[0],n=i[1],r=i[2],s=o-C,a=n-I;return Math.abs(Math.sqrt(s*s+a*a)-r)}},{key:"_getDistanceToLine",value:function(g,t,A,e,C,I){var i=A-g,o=e-t,n=((C-g)*i+(I-t)*o)/(i*i+o*o);n>1?n=1:n<0&&(n=0);var r=g+n*i-C,s=t+n*o-I;return Math.sqrt(r*r+s*s)}},{key:"getArrowData",value:function(g,t,A,e,C,I){var i,o,n,r,s,a,d,h=I.width;"from"===t?(n=this.from,r=this.to,s=I.fromArrowScale<0,a=Math.abs(I.fromArrowScale),d=I.fromArrowType):"to"===t?(n=this.to,r=this.from,s=I.toArrowScale<0,a=Math.abs(I.toArrowScale),d=I.toArrowType):(n=this.to,r=this.from,s=I.middleArrowScale<0,a=Math.abs(I.middleArrowScale),d=I.middleArrowType);var l=15*a+3*h;if(n!=r){var c=l/sx(n.x-r.x,n.y-r.y);if("middle"!==t)if(!0===this.options.smooth.enabled){var u=this._findBorderPosition(n,g,{via:A}),p=this.getPoint(u.t+c*("from"===t?1:-1),A);i=Math.atan2(u.y-p.y,u.x-p.x),o=u}else i=Math.atan2(n.y-r.y,n.x-r.x),o=this._findBorderPosition(n,g);else{var f=(s?-c:c)/2,v=this.getPoint(.5+f,A),y=this.getPoint(.5-f,A);i=Math.atan2(v.y-y.y,v.x-y.x),o=this.getPoint(.5,A)}}else{var m=Xa(this._getCircleData(g),3),b=m[0],w=m[1],k=m[2];if("from"===t){var x=this.options.selfReference.angle,E=this.options.selfReference.angle+Math.PI,O=this._findBorderPositionCircle(this.from,g,{x:b,y:w,low:x,high:E,direction:-1});i=-2*O.t*Math.PI+1.5*Math.PI+.1*Math.PI,o=O}else if("to"===t){var T=this.options.selfReference.angle,D=this.options.selfReference.angle+Math.PI,N=this._findBorderPositionCircle(this.from,g,{x:b,y:w,low:T,high:D,direction:1});i=-2*N.t*Math.PI+1.5*Math.PI-1.1*Math.PI,o=N}else{var R=this.options.selfReference.angle/(2*Math.PI);o=this._pointOnCircle(b,w,k,R),i=-2*R*Math.PI+1.5*Math.PI+.1*Math.PI}}return{point:o,core:{x:o.x-.9*l*Math.cos(i),y:o.y-.9*l*Math.sin(i)},angle:i,length:l,type:d}}},{key:"drawArrowHead",value:function(g,t,A,e,C){g.strokeStyle=this.getColor(g,t),g.fillStyle=g.strokeStyle,g.lineWidth=t.width,xx.draw(g,C)&&(this.enableShadow(g,t),Rc(g).call(g),this.disableShadow(g,t))}},{key:"enableShadow",value:function(g,t){!0===t.shadow&&(g.shadowColor=t.shadowColor,g.shadowBlur=t.shadowSize,g.shadowOffsetX=t.shadowX,g.shadowOffsetY=t.shadowY)}},{key:"disableShadow",value:function(g,t){!0===t.shadow&&(g.shadowColor="rgba(0,0,0,0)",g.shadowBlur=0,g.shadowOffsetX=0,g.shadowOffsetY=0)}},{key:"drawBackground",value:function(g,t){if(!1!==t.background){var A={strokeStyle:g.strokeStyle,lineWidth:g.lineWidth,dashes:g.dashes};g.strokeStyle=t.backgroundColor,g.lineWidth=t.backgroundSize,this.setStrokeDashed(g,t.backgroundDashes),g.stroke(),g.strokeStyle=A.strokeStyle,g.lineWidth=A.lineWidth,g.dashes=A.dashes,this.setStrokeDashed(g,t.dashes)}}},{key:"setStrokeDashed",value:function(g,t){if(!1!==t)if(void 0!==g.setLineDash){var A=sd(t)?t:[5,5];g.setLineDash(A)}else console.warn("setLineDash is not supported in this browser. The dashed stroke cannot be used.");else void 0!==g.setLineDash?g.setLineDash([]):console.warn("setLineDash is not supported in this browser. The dashed stroke cannot be used.")}}]),g}();function Dx(g,t){var A=yd(g);if(rs){var e=rs(g);t&&(e=Uh(e).call(e,(function(t){return ys(g,t).enumerable}))),A.push.apply(A,e)}return A}function Nx(g){for(var t=1;t<arguments.length;t++){var A,e,C=null!=arguments[t]?arguments[t]:{};t%2?Sd(A=Dx(Object(C),!0)).call(A,(function(t){Js(g,t,C[t])})):Rs?Fs(g,Rs(C)):Sd(e=Dx(Object(C))).call(e,(function(t){_s(g,t,ys(C,t))}))}return g}function Rx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Px=function(g){dm(A,g);var t=Rx(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_findBorderPositionBezier",value:function(g,t){var A,e,C=arguments.length>2&&void 0!==arguments[2]?arguments[2]:this._getViaCoordinates(),I=10,i=.2,o=!1,n=1,r=0,s=this.to,a=this.options.endPointOffset?this.options.endPointOffset.to:0;g.id===this.from.id&&(s=this.from,o=!0,a=this.options.endPointOffset?this.options.endPointOffset.from:0),!1===this.options.arrowStrikethrough&&(a=0);var d=0;do{e=.5*(r+n),A=this.getPoint(e,C);var h=Math.atan2(s.y-A.y,s.x-A.x),l=s.distanceToBorder(t,h)+a,c=Math.sqrt(Math.pow(A.x-s.x,2)+Math.pow(A.y-s.y,2)),u=l-c;if(Math.abs(u)<i)break;u<0?!1===o?r=e:n=e:!1===o?n=e:r=e,++d}while(r<=n&&d<I);return Nx(Nx({},A),{},{t:e})}},{key:"_getDistanceToBezierEdge",value:function(g,t,A,e,C,I,i){var o,n,r,s,a,d=1e9,h=g,l=t;for(n=1;n<10;n++)r=.1*n,s=Math.pow(1-r,2)*g+2*r*(1-r)*i.x+Math.pow(r,2)*A,a=Math.pow(1-r,2)*t+2*r*(1-r)*i.y+Math.pow(r,2)*e,n>0&&(d=(o=this._getDistanceToLine(h,l,s,a,C,I))<d?o:d),h=s,l=a;return d}},{key:"_bezierCurve",value:function(g,t,A,e){g.beginPath(),g.moveTo(this.fromPoint.x,this.fromPoint.y),null!=A&&null!=A.x?null!=e&&null!=e.x?g.bezierCurveTo(A.x,A.y,e.x,e.y,this.toPoint.x,this.toPoint.y):g.quadraticCurveTo(A.x,A.y,this.toPoint.x,this.toPoint.y):g.lineTo(this.toPoint.x,this.toPoint.y),this.drawBackground(g,t),this.enableShadow(g,t),g.stroke(),this.disableShadow(g,t)}},{key:"getViaNode",value:function(){return this._getViaCoordinates()}}]),A}(Tx);function Mx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Bx=function(g){dm(A,g);var t=Mx(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C)).via=I.via,I._boundFunction=function(){I.positionBezierNode()},I._body.emitter.on("_repositionBezierNodes",I._boundFunction),I}return Xs(A,[{key:"setOptions",value:function(g){Ix(cm(A.prototype),"setOptions",this).call(this,g);var t=!1;this.options.physics!==g.physics&&(t=!0),this.options=g,this.id=this.options.id,this.from=this._body.nodes[this.options.from],this.to=this._body.nodes[this.options.to],this.setupSupportNode(),this.connect(),!0===t&&(this.via.setOptions({physics:this.options.physics}),this.positionBezierNode())}},{key:"connect",value:function(){this.from=this._body.nodes[this.options.from],this.to=this._body.nodes[this.options.to],void 0===this.from||void 0===this.to||!1===this.options.physics||this.from.id===this.to.id?this.via.setOptions({physics:!1}):this.via.setOptions({physics:!0})}},{key:"cleanup",value:function(){return this._body.emitter.off("_repositionBezierNodes",this._boundFunction),void 0!==this.via&&(delete this._body.nodes[this.via.id],this.via=void 0,!0)}},{key:"setupSupportNode",value:function(){if(void 0===this.via){var g="edgeId:"+this.id,t=this._body.functions.createNode({id:g,shape:"circle",physics:!0,hidden:!0});this._body.nodes[g]=t,this.via=t,this.via.parentEdgeId=this.id,this.positionBezierNode()}}},{key:"positionBezierNode",value:function(){void 0!==this.via&&void 0!==this.from&&void 0!==this.to?(this.via.x=.5*(this.from.x+this.to.x),this.via.y=.5*(this.from.y+this.to.y)):void 0!==this.via&&(this.via.x=0,this.via.y=0)}},{key:"_line",value:function(g,t,A){this._bezierCurve(g,t,A)}},{key:"_getViaCoordinates",value:function(){return this.via}},{key:"getViaNode",value:function(){return this.via}},{key:"getPoint",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.via;if(this.from===this.to){var A=this._getCircleData(),e=Xa(A,3),C=e[0],I=e[1],i=e[2],o=2*Math.PI*(1-g);return{x:C+i*Math.sin(o),y:I+i-i*(1-Math.cos(o))}}return{x:Math.pow(1-g,2)*this.fromPoint.x+2*g*(1-g)*t.x+Math.pow(g,2)*this.toPoint.x,y:Math.pow(1-g,2)*this.fromPoint.y+2*g*(1-g)*t.y+Math.pow(g,2)*this.toPoint.y}}},{key:"_findBorderPosition",value:function(g,t){return this._findBorderPositionBezier(g,t,this.via)}},{key:"_getDistanceToEdge",value:function(g,t,A,e,C,I){return this._getDistanceToBezierEdge(g,t,A,e,C,I,this.via)}}]),A}(Px);function zx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Zx=function(g){dm(A,g);var t=zx(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_line",value:function(g,t,A){this._bezierCurve(g,t,A)}},{key:"getViaNode",value:function(){return this._getViaCoordinates()}},{key:"_getViaCoordinates",value:function(){var g,t,A=this.options.smooth.roundness,e=this.options.smooth.type,C=Math.abs(this.from.x-this.to.x),I=Math.abs(this.from.y-this.to.y);if("discrete"===e||"diagonalCross"===e){var i,o;i=o=C<=I?A*I:A*C,this.from.x>this.to.x&&(i=-i),this.from.y>=this.to.y&&(o=-o);var n=this.from.x+i,r=this.from.y+o;return"discrete"===e&&(C<=I?n=C<A*I?this.from.x:n:r=I<A*C?this.from.y:r),{x:n,y:r}}if("straightCross"===e){var s=(1-A)*C,a=(1-A)*I;return C<=I?(s=0,this.from.y<this.to.y&&(a=-a)):(this.from.x<this.to.x&&(s=-s),a=0),{x:this.to.x+s,y:this.to.y+a}}if("horizontal"===e){var d=(1-A)*C;return this.from.x<this.to.x&&(d=-d),{x:this.to.x+d,y:this.from.y}}if("vertical"===e){var h=(1-A)*I;return this.from.y<this.to.y&&(h=-h),{x:this.from.x,y:this.to.y+h}}if("curvedCW"===e){C=this.to.x-this.from.x,I=this.from.y-this.to.y;var l=Math.sqrt(C*C+I*I),c=Math.PI,u=(Math.atan2(I,C)+(.5*A+.5)*c)%(2*c);return{x:this.from.x+(.5*A+.5)*l*Math.sin(u),y:this.from.y+(.5*A+.5)*l*Math.cos(u)}}if("curvedCCW"===e){C=this.to.x-this.from.x,I=this.from.y-this.to.y;var p=Math.sqrt(C*C+I*I),f=Math.PI,v=(Math.atan2(I,C)+(.5*-A+.5)*f)%(2*f);return{x:this.from.x+(.5*A+.5)*p*Math.sin(v),y:this.from.y+(.5*A+.5)*p*Math.cos(v)}}g=t=C<=I?A*I:A*C,this.from.x>this.to.x&&(g=-g),this.from.y>=this.to.y&&(t=-t);var y=this.from.x+g,m=this.from.y+t;return C<=I?y=this.from.x<=this.to.x?this.to.x<y?this.to.x:y:this.to.x>y?this.to.x:y:m=this.from.y>=this.to.y?this.to.y>m?this.to.y:m:this.to.y<m?this.to.y:m,{x:y,y:m}}},{key:"_findBorderPosition",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};return this._findBorderPositionBezier(g,t,A.via)}},{key:"_getDistanceToEdge",value:function(g,t,A,e,C,I){var i=arguments.length>6&&void 0!==arguments[6]?arguments[6]:this._getViaCoordinates();return this._getDistanceToBezierEdge(g,t,A,e,C,I,i)}},{key:"getPoint",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._getViaCoordinates(),A=g,e=Math.pow(1-A,2)*this.fromPoint.x+2*A*(1-A)*t.x+Math.pow(A,2)*this.toPoint.x,C=Math.pow(1-A,2)*this.fromPoint.y+2*A*(1-A)*t.y+Math.pow(A,2)*this.toPoint.y;return{x:e,y:C}}}]),A}(Px);function Sx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Fx=function(g){dm(A,g);var t=Sx(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_getDistanceToBezierEdge2",value:function(g,t,A,e,C,I,i,o){for(var n=1e9,r=g,s=t,a=[0,0,0,0],d=1;d<10;d++){var h=.1*d;a[0]=Math.pow(1-h,3),a[1]=3*h*Math.pow(1-h,2),a[2]=3*Math.pow(h,2)*(1-h),a[3]=Math.pow(h,3);var l=a[0]*g+a[1]*i.x+a[2]*o.x+a[3]*A,c=a[0]*t+a[1]*i.y+a[2]*o.y+a[3]*e;if(d>0){var u=this._getDistanceToLine(r,s,l,c,C,I);n=u<n?u:n}r=l,s=c}return n}}]),A}(Px);function Gx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Lx=function(g){dm(A,g);var t=Gx(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_line",value:function(g,t,A){var e=A[0],C=A[1];this._bezierCurve(g,t,e,C)}},{key:"_getViaCoordinates",value:function(){var g,t,A,e,C=this.from.x-this.to.x,I=this.from.y-this.to.y,i=this.options.smooth.roundness;return(Math.abs(C)>Math.abs(I)||!0===this.options.smooth.forceDirection||"horizontal"===this.options.smooth.forceDirection)&&"vertical"!==this.options.smooth.forceDirection?(t=this.from.y,e=this.to.y,g=this.from.x-i*C,A=this.to.x+i*C):(t=this.from.y-i*I,e=this.to.y+i*I,g=this.from.x,A=this.to.x),[{x:g,y:t},{x:A,y:e}]}},{key:"getViaNode",value:function(){return this._getViaCoordinates()}},{key:"_findBorderPosition",value:function(g,t){return this._findBorderPositionBezier(g,t)}},{key:"_getDistanceToEdge",value:function(g,t,A,e,C,I){var i=arguments.length>6&&void 0!==arguments[6]?arguments[6]:this._getViaCoordinates(),o=Xa(i,2),n=o[0],r=o[1];return this._getDistanceToBezierEdge2(g,t,A,e,C,I,n,r)}},{key:"getPoint",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._getViaCoordinates(),A=Xa(t,2),e=A[0],C=A[1],I=g,i=[Math.pow(1-I,3),3*I*Math.pow(1-I,2),3*Math.pow(I,2)*(1-I),Math.pow(I,3)],o=i[0]*this.fromPoint.x+i[1]*e.x+i[2]*C.x+i[3]*this.toPoint.x,n=i[0]*this.fromPoint.y+i[1]*e.y+i[2]*C.y+i[3]*this.toPoint.y;return{x:o,y:n}}}]),A}(Fx);function jx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var Vx=function(g){dm(A,g);var t=jx(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_line",value:function(g,t){g.beginPath(),g.moveTo(this.fromPoint.x,this.fromPoint.y),g.lineTo(this.toPoint.x,this.toPoint.y),this.enableShadow(g,t),g.stroke(),this.disableShadow(g,t)}},{key:"getViaNode",value:function(){}},{key:"getPoint",value:function(g){return{x:(1-g)*this.fromPoint.x+g*this.toPoint.x,y:(1-g)*this.fromPoint.y+g*this.toPoint.y}}},{key:"_findBorderPosition",value:function(g,t){var A=this.to,e=this.from;g.id===this.from.id&&(A=this.from,e=this.to);var C=Math.atan2(A.y-e.y,A.x-e.x),I=A.x-e.x,i=A.y-e.y,o=Math.sqrt(I*I+i*i),n=(o-g.distanceToBorder(t,C))/o;return{x:(1-n)*e.x+n*A.x,y:(1-n)*e.y+n*A.y,t:0}}},{key:"_getDistanceToEdge",value:function(g,t,A,e,C,I){return this._getDistanceToLine(g,t,A,e,C,I)}}]),A}(Tx),Yx=function(){function g(t,A,e,C,I){if(Us(this,g),void 0===A)throw new Error("No body provided");this.options=If(C),this.globalOptions=C,this.defaultOptions=I,this.body=A,this.imagelist=e,this.id=void 0,this.fromId=void 0,this.toId=void 0,this.selected=!1,this.hover=!1,this.labelDirty=!0,this.baseWidth=this.options.width,this.baseFontSize=this.options.font.size,this.from=void 0,this.to=void 0,this.edgeType=void 0,this.connected=!1,this.labelModule=new ek(this.body,this.options,!0),this.setOptions(t)}return Xs(g,[{key:"setOptions",value:function(t){if(t){var A=void 0!==t.physics&&this.options.physics!==t.physics||void 0!==t.hidden&&(this.options.hidden||!1)!==(t.hidden||!1)||void 0!==t.from&&this.options.from!==t.from||void 0!==t.to&&this.options.to!==t.to;g.parseOptions(this.options,t,!0,this.globalOptions),void 0!==t.id&&(this.id=t.id),void 0!==t.from&&(this.fromId=t.from),void 0!==t.to&&(this.toId=t.to),void 0!==t.title&&(this.title=t.title),void 0!==t.value&&(t.value=jw(t.value));var e=[t,this.options,this.defaultOptions];return this.chooser=Hw("edge",e),this.updateLabelModule(t),A=this.updateEdgeType()||A,this._setInteractionWidths(),this.connect(),A}}},{key:"getFormattingValues",value:function(){var g=!0===this.options.arrows.to||!0===this.options.arrows.to.enabled,t=!0===this.options.arrows.from||!0===this.options.arrows.from.enabled,A=!0===this.options.arrows.middle||!0===this.options.arrows.middle.enabled,e=this.options.color.inherit,C={toArrow:g,toArrowScale:this.options.arrows.to.scaleFactor,toArrowType:this.options.arrows.to.type,toArrowSrc:this.options.arrows.to.src,toArrowImageWidth:this.options.arrows.to.imageWidth,toArrowImageHeight:this.options.arrows.to.imageHeight,middleArrow:A,middleArrowScale:this.options.arrows.middle.scaleFactor,middleArrowType:this.options.arrows.middle.type,middleArrowSrc:this.options.arrows.middle.src,middleArrowImageWidth:this.options.arrows.middle.imageWidth,middleArrowImageHeight:this.options.arrows.middle.imageHeight,fromArrow:t,fromArrowScale:this.options.arrows.from.scaleFactor,fromArrowType:this.options.arrows.from.type,fromArrowSrc:this.options.arrows.from.src,fromArrowImageWidth:this.options.arrows.from.imageWidth,fromArrowImageHeight:this.options.arrows.from.imageHeight,arrowStrikethrough:this.options.arrowStrikethrough,color:e?void 0:this.options.color.color,inheritsColor:e,opacity:this.options.color.opacity,hidden:this.options.hidden,length:this.options.length,shadow:this.options.shadow.enabled,shadowColor:this.options.shadow.color,shadowSize:this.options.shadow.size,shadowX:this.options.shadow.x,shadowY:this.options.shadow.y,dashes:this.options.dashes,width:this.options.width,background:this.options.background.enabled,backgroundColor:this.options.background.color,backgroundSize:this.options.background.size,backgroundDashes:this.options.background.dashes};if(this.selected||this.hover)if(!0===this.chooser){if(this.selected){var I=this.options.selectionWidth;"function"==typeof I?C.width=I(C.width):"number"==typeof I&&(C.width+=I),C.width=Math.max(C.width,.3/this.body.view.scale),C.color=this.options.color.highlight,C.shadow=this.options.shadow.enabled}else if(this.hover){var i=this.options.hoverWidth;"function"==typeof i?C.width=i(C.width):"number"==typeof i&&(C.width+=i),C.width=Math.max(C.width,.3/this.body.view.scale),C.color=this.options.color.hover,C.shadow=this.options.shadow.enabled}}else"function"==typeof this.chooser&&(this.chooser(C,this.options.id,this.selected,this.hover),void 0!==C.color&&(C.inheritsColor=!1),!1===C.shadow&&(C.shadowColor===this.options.shadow.color&&C.shadowSize===this.options.shadow.size&&C.shadowX===this.options.shadow.x&&C.shadowY===this.options.shadow.y||(C.shadow=!0)));else C.shadow=this.options.shadow.enabled,C.width=Math.max(C.width,.3/this.body.view.scale);return C}},{key:"updateLabelModule",value:function(g){var t=[g,this.options,this.globalOptions,this.defaultOptions];this.labelModule.update(this.options,t),void 0!==this.labelModule.baseSize&&(this.baseFontSize=this.labelModule.baseSize)}},{key:"updateEdgeType",value:function(){var g=this.options.smooth,t=!1,A=!0;return void 0!==this.edgeType&&((this.edgeType instanceof Bx&&!0===g.enabled&&"dynamic"===g.type||this.edgeType instanceof Lx&&!0===g.enabled&&"cubicBezier"===g.type||this.edgeType instanceof Zx&&!0===g.enabled&&"dynamic"!==g.type&&"cubicBezier"!==g.type||this.edgeType instanceof Vx&&!1===g.type.enabled)&&(A=!1),!0===A&&(t=this.cleanup())),!0===A?!0===g.enabled?"dynamic"===g.type?(t=!0,this.edgeType=new Bx(this.options,this.body,this.labelModule)):"cubicBezier"===g.type?this.edgeType=new Lx(this.options,this.body,this.labelModule):this.edgeType=new Zx(this.options,this.body,this.labelModule):this.edgeType=new Vx(this.options,this.body,this.labelModule):this.edgeType.setOptions(this.options),t}},{key:"connect",value:function(){this.disconnect(),this.from=this.body.nodes[this.fromId]||void 0,this.to=this.body.nodes[this.toId]||void 0,this.connected=void 0!==this.from&&void 0!==this.to,!0===this.connected?(this.from.attachEdge(this),this.to.attachEdge(this)):(this.from&&this.from.detachEdge(this),this.to&&this.to.detachEdge(this)),this.edgeType.connect()}},{key:"disconnect",value:function(){this.from&&(this.from.detachEdge(this),this.from=void 0),this.to&&(this.to.detachEdge(this),this.to=void 0),this.connected=!1}},{key:"getTitle",value:function(){return this.title}},{key:"isSelected",value:function(){return this.selected}},{key:"getValue",value:function(){return this.options.value}},{key:"setValueRange",value:function(g,t,A){if(void 0!==this.options.value){var e=this.options.scaling.customScalingFunction(g,t,A,this.options.value),C=this.options.scaling.max-this.options.scaling.min;if(!0===this.options.scaling.label.enabled){var I=this.options.scaling.label.max-this.options.scaling.label.min;this.options.font.size=this.options.scaling.label.min+e*I}this.options.width=this.options.scaling.min+e*C}else this.options.width=this.baseWidth,this.options.font.size=this.baseFontSize;this._setInteractionWidths(),this.updateLabelModule()}},{key:"_setInteractionWidths",value:function(){"function"==typeof this.options.hoverWidth?this.edgeType.hoverWidth=this.options.hoverWidth(this.options.width):this.edgeType.hoverWidth=this.options.hoverWidth+this.options.width,"function"==typeof this.options.selectionWidth?this.edgeType.selectionWidth=this.options.selectionWidth(this.options.width):this.edgeType.selectionWidth=this.options.selectionWidth+this.options.width}},{key:"draw",value:function(g){var t=this.getFormattingValues();if(!t.hidden){var A=this.edgeType.getViaNode();this.edgeType.drawLine(g,t,this.selected,this.hover,A),this.drawLabel(g,A)}}},{key:"drawArrows",value:function(g){var t=this.getFormattingValues();if(!t.hidden){var A=this.edgeType.getViaNode(),e={};this.edgeType.fromPoint=this.edgeType.from,this.edgeType.toPoint=this.edgeType.to,t.fromArrow&&(e.from=this.edgeType.getArrowData(g,"from",A,this.selected,this.hover,t),!1===t.arrowStrikethrough&&(this.edgeType.fromPoint=e.from.core),t.fromArrowSrc&&(e.from.image=this.imagelist.load(t.fromArrowSrc)),t.fromArrowImageWidth&&(e.from.imageWidth=t.fromArrowImageWidth),t.fromArrowImageHeight&&(e.from.imageHeight=t.fromArrowImageHeight)),t.toArrow&&(e.to=this.edgeType.getArrowData(g,"to",A,this.selected,this.hover,t),!1===t.arrowStrikethrough&&(this.edgeType.toPoint=e.to.core),t.toArrowSrc&&(e.to.image=this.imagelist.load(t.toArrowSrc)),t.toArrowImageWidth&&(e.to.imageWidth=t.toArrowImageWidth),t.toArrowImageHeight&&(e.to.imageHeight=t.toArrowImageHeight)),t.middleArrow&&(e.middle=this.edgeType.getArrowData(g,"middle",A,this.selected,this.hover,t),t.middleArrowSrc&&(e.middle.image=this.imagelist.load(t.middleArrowSrc)),t.middleArrowImageWidth&&(e.middle.imageWidth=t.middleArrowImageWidth),t.middleArrowImageHeight&&(e.middle.imageHeight=t.middleArrowImageHeight)),t.fromArrow&&this.edgeType.drawArrowHead(g,t,this.selected,this.hover,e.from),t.middleArrow&&this.edgeType.drawArrowHead(g,t,this.selected,this.hover,e.middle),t.toArrow&&this.edgeType.drawArrowHead(g,t,this.selected,this.hover,e.to)}}},{key:"drawLabel",value:function(g,t){if(void 0!==this.options.label){var A,e=this.from,C=this.to;if(this.labelModule.differentState(this.selected,this.hover)&&this.labelModule.getTextSize(g,this.selected,this.hover),e.id!=C.id){this.labelModule.pointToSelf=!1,A=this.edgeType.getPoint(.5,t),g.save();var I=this._getRotation(g);0!=I.angle&&(g.translate(I.x,I.y),g.rotate(I.angle)),this.labelModule.draw(g,A.x,A.y,this.selected,this.hover),g.restore()}else{this.labelModule.pointToSelf=!0;var i=Jw(g,this.options.selfReference.angle,this.options.selfReference.size,e);A=this._pointOnCircle(i.x,i.y,this.options.selfReference.size,this.options.selfReference.angle),this.labelModule.draw(g,A.x,A.y,this.selected,this.hover)}}}},{key:"getItemsOnPoint",value:function(g){var t=[];if(this.labelModule.visible()){var A=this._getRotation();Kw(this.labelModule.getSize(),g,A)&&t.push({edgeId:this.id,labelId:0})}var e={left:g.x,top:g.y};return this.isOverlappingWith(e)&&t.push({edgeId:this.id}),t}},{key:"isOverlappingWith",value:function(g){if(this.connected){var t=this.from.x,A=this.from.y,e=this.to.x,C=this.to.y,I=g.left,i=g.top;return this.edgeType.getDistanceToEdge(t,A,e,C,I,i)<10}return!1}},{key:"_getRotation",value:function(g){var t=this.edgeType.getViaNode(),A=this.edgeType.getPoint(.5,t);void 0!==g&&this.labelModule.calculateLabelSize(g,this.selected,this.hover,A.x,A.y);var e={x:A.x,y:this.labelModule.size.yLine,angle:0};if(!this.labelModule.visible())return e;if("horizontal"===this.options.font.align)return e;var C=this.from.y-this.to.y,I=this.from.x-this.to.x,i=Math.atan2(C,I);return(i<-1&&I<0||i>0&&I<0)&&(i+=Math.PI),e.angle=i,e}},{key:"_pointOnCircle",value:function(g,t,A,e){return{x:g+A*Math.cos(e),y:t-A*Math.sin(e)}}},{key:"select",value:function(){this.selected=!0}},{key:"unselect",value:function(){this.selected=!1}},{key:"cleanup",value:function(){return this.edgeType.cleanup()}},{key:"remove",value:function(){this.cleanup(),this.disconnect(),delete this.body.edges[this.id]}},{key:"endPointsValid",value:function(){return void 0!==this.body.nodes[this.fromId]&&void 0!==this.body.nodes[this.toId]}}],[{key:"parseOptions",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]&&arguments[2],e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},C=arguments.length>4&&void 0!==arguments[4]&&arguments[4],I=["endPointOffset","arrowStrikethrough","id","from","hidden","hoverWidth","labelHighlightBold","length","line","opacity","physics","scaling","selectionWidth","selfReferenceSize","selfReference","to","title","value","width","font","chosen","widthConstraint"];if(Vp(I,g,t,A),void 0!==t.endPointOffset&&void 0!==t.endPointOffset.from&&(Zy(t.endPointOffset.from)?g.endPointOffset.from=t.endPointOffset.from:(g.endPointOffset.from=void 0!==e.endPointOffset.from?e.endPointOffset.from:0,console.error("endPointOffset.from is not a valid number"))),void 0!==t.endPointOffset&&void 0!==t.endPointOffset.to&&(Zy(t.endPointOffset.to)?g.endPointOffset.to=t.endPointOffset.to:(g.endPointOffset.to=void 0!==e.endPointOffset.to?e.endPointOffset.to:0,console.error("endPointOffset.to is not a valid number"))),Xw(t.label)?g.label=t.label:Xw(g.label)||(g.label=void 0),of(g,t,"smooth",e),of(g,t,"shadow",e),of(g,t,"background",e),void 0!==t.dashes&&null!==t.dashes?g.dashes=t.dashes:!0===A&&null===t.dashes&&(g.dashes=Vl(e.dashes)),void 0!==t.scaling&&null!==t.scaling?(void 0!==t.scaling.min&&(g.scaling.min=t.scaling.min),void 0!==t.scaling.max&&(g.scaling.max=t.scaling.max),of(g.scaling,t.scaling,"label",e.scaling)):!0===A&&null===t.scaling&&(g.scaling=Vl(e.scaling)),void 0!==t.arrows&&null!==t.arrows)if("string"==typeof t.arrows){var i=t.arrows.toLowerCase();g.arrows.to.enabled=-1!=zl(i).call(i,"to"),g.arrows.middle.enabled=-1!=zl(i).call(i,"middle"),g.arrows.from.enabled=-1!=zl(i).call(i,"from")}else{if("object"!==$a(t.arrows))throw new Error("The arrow newOptions can only be an object or a string. Refer to the documentation. You used:"+rc(t.arrows));of(g.arrows,t.arrows,"to",e.arrows),of(g.arrows,t.arrows,"middle",e.arrows),of(g.arrows,t.arrows,"from",e.arrows)}else!0===A&&null===t.arrows&&(g.arrows=Vl(e.arrows));if(void 0!==t.color&&null!==t.color){var o=Fp(t.color)?{color:t.color,highlight:t.color,hover:t.color,inherit:!1,opacity:1}:t.color,n=g.color;if(C)Wp(n,e.color,!1,A);else for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&delete n[r];if(Fp(n))n.color=n,n.highlight=n,n.hover=n,n.inherit=!1,void 0===o.opacity&&(n.opacity=1);else{var s=!1;void 0!==o.color&&(n.color=o.color,s=!0),void 0!==o.highlight&&(n.highlight=o.highlight,s=!0),void 0!==o.hover&&(n.hover=o.hover,s=!0),void 0!==o.inherit&&(n.inherit=o.inherit),void 0!==o.opacity&&(n.opacity=Math.min(1,Math.max(0,o.opacity))),!0===s?n.inherit=!1:void 0===n.inherit&&(n.inherit="from")}}else!0===A&&null===t.color&&(g.color=If(e.color));!0===A&&null===t.font&&(g.font=If(e.font)),Object.prototype.hasOwnProperty.call(t,"selfReferenceSize")&&(console.warn("The selfReferenceSize property has been deprecated. Please use selfReference property instead. The selfReference can be set like thise selfReference:{size:30, angle:Math.PI / 4}"),g.selfReference.size=t.selfReferenceSize)}}]),g}(),Wx=function(){function g(t,A,e){var C,I=this;Us(this,g),this.body=t,this.images=A,this.groups=e,this.body.functions.createEdge=Me(C=this.create).call(C,this),this.edgesListeners={add:function(g,t){I.add(t.items)},update:function(g,t){I.update(t.items)},remove:function(g,t){I.remove(t.items)}},this.options={},this.defaultOptions={arrows:{to:{enabled:!1,scaleFactor:1,type:"arrow"},middle:{enabled:!1,scaleFactor:1,type:"arrow"},from:{enabled:!1,scaleFactor:1,type:"arrow"}},endPointOffset:{from:0,to:0},arrowStrikethrough:!0,color:{color:"#848484",highlight:"#848484",hover:"#848484",inherit:"from",opacity:1},dashes:!1,font:{color:"#343434",size:14,face:"arial",background:"none",strokeWidth:2,strokeColor:"#ffffff",align:"horizontal",multi:!1,vadjust:0,bold:{mod:"bold"},boldital:{mod:"bold italic"},ital:{mod:"italic"},mono:{mod:"",size:15,face:"courier new",vadjust:2}},hidden:!1,hoverWidth:1.5,label:void 0,labelHighlightBold:!0,length:void 0,physics:!0,scaling:{min:1,max:15,label:{enabled:!0,min:14,max:30,maxVisible:30,drawThreshold:5},customScalingFunction:function(g,t,A,e){if(t===g)return.5;var C=1/(t-g);return Math.max(0,(e-g)*C)}},selectionWidth:1.5,selfReference:{size:20,angle:Math.PI/4,renderBehindTheNode:!0},shadow:{enabled:!1,color:"rgba(0,0,0,0.5)",size:10,x:5,y:5},background:{enabled:!1,color:"rgba(111,111,111,1)",size:10,dashes:!1},smooth:{enabled:!0,type:"dynamic",forceDirection:"none",roundness:.5},title:void 0,width:1,value:void 0},Wp(this.options,this.defaultOptions),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g,t,A=this;this.body.emitter.on("_forceDisableDynamicCurves",(function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];"dynamic"===g&&(g="continuous");var e=!1;for(var C in A.body.edges)if(Object.prototype.hasOwnProperty.call(A.body.edges,C)){var I=A.body.edges[C],i=A.body.data.edges.get(C);if(null!=i){var o=i.smooth;void 0!==o&&!0===o.enabled&&"dynamic"===o.type&&(void 0===g?I.setOptions({smooth:!1}):I.setOptions({smooth:{type:g}}),e=!0)}}!0===t&&!0===e&&A.body.emitter.emit("_dataChanged")})),this.body.emitter.on("_dataUpdated",(function(){A.reconnectEdges()})),this.body.emitter.on("refreshEdges",Me(g=this.refresh).call(g,this)),this.body.emitter.on("refresh",Me(t=this.refresh).call(t,this)),this.body.emitter.on("destroy",(function(){Hp(A.edgesListeners,(function(g,t){A.body.data.edges&&A.body.data.edges.off(t,g)})),delete A.body.functions.createEdge,delete A.edgesListeners.add,delete A.edgesListeners.update,delete A.edgesListeners.remove,delete A.edgesListeners}))}},{key:"setOptions",value:function(g){if(void 0!==g){Yx.parseOptions(this.options,g,!0,this.defaultOptions,!0);var t=!1;if(void 0!==g.smooth)for(var A in this.body.edges)Object.prototype.hasOwnProperty.call(this.body.edges,A)&&(t=this.body.edges[A].updateEdgeType()||t);if(void 0!==g.font)for(var e in this.body.edges)Object.prototype.hasOwnProperty.call(this.body.edges,e)&&this.body.edges[e].updateLabelModule();void 0===g.hidden&&void 0===g.physics&&!0!==t||this.body.emitter.emit("_dataChanged")}}},{key:"setData",value:function(g){var t=this,A=arguments.length>1&&void 0!==arguments[1]&&arguments[1],e=this.body.data.edges;if(Nw("id",g))this.body.data.edges=g;else if(sd(g))this.body.data.edges=new Ow,this.body.data.edges.add(g);else{if(g)throw new TypeError("Array or DataSet expected");this.body.data.edges=new Ow}if(e&&Hp(this.edgesListeners,(function(g,t){e.off(t,g)})),this.body.edges={},this.body.data.edges){Hp(this.edgesListeners,(function(g,A){t.body.data.edges.on(A,g)}));var C=this.body.data.edges.getIds();this.add(C,!0)}this.body.emitter.emit("_adjustEdgesForHierarchicalLayout"),!1===A&&this.body.emitter.emit("_dataChanged")}},{key:"add",value:function(g){for(var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],A=this.body.edges,e=this.body.data.edges,C=0;C<g.length;C++){var I=g[C],i=A[I];i&&i.disconnect();var o=e.get(I,{showInternalIds:!0});A[I]=this.create(o)}this.body.emitter.emit("_adjustEdgesForHierarchicalLayout"),!1===t&&this.body.emitter.emit("_dataChanged")}},{key:"update",value:function(g){for(var t=this.body.edges,A=this.body.data.edges,e=!1,C=0;C<g.length;C++){var I=g[C],i=A.get(I),o=t[I];void 0!==o?(o.disconnect(),e=o.setOptions(i)||e,o.connect()):(this.body.edges[I]=this.create(i),e=!0)}!0===e?(this.body.emitter.emit("_adjustEdgesForHierarchicalLayout"),this.body.emitter.emit("_dataChanged")):this.body.emitter.emit("_dataUpdated")}},{key:"remove",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(0!==g.length){var A=this.body.edges;Hp(g,(function(g){var t=A[g];void 0!==t&&t.remove()})),t&&this.body.emitter.emit("_dataChanged")}}},{key:"refresh",value:function(){var g=this;Hp(this.body.edges,(function(t,A){var e=g.body.data.edges.get(A);void 0!==e&&t.setOptions(e)}))}},{key:"create",value:function(g){return new Yx(g,this.body,this.images,this.options,this.defaultOptions)}},{key:"reconnectEdges",value:function(){var g,t=this.body.nodes,A=this.body.edges;for(g in t)Object.prototype.hasOwnProperty.call(t,g)&&(t[g].edges=[]);for(g in A)if(Object.prototype.hasOwnProperty.call(A,g)){var e=A[g];e.from=null,e.to=null,e.connect()}}},{key:"getConnectedNodes",value:function(g){var t=[];if(void 0!==this.body.edges[g]){var A=this.body.edges[g];void 0!==A.fromId&&t.push(A.fromId),void 0!==A.toId&&t.push(A.toId)}return t}},{key:"_updateState",value:function(){this._addMissingEdges(),this._removeInvalidEdges()}},{key:"_removeInvalidEdges",value:function(){var g=this,t=[];Hp(this.body.edges,(function(A,e){var C=g.body.nodes[A.toId],I=g.body.nodes[A.fromId];void 0!==C&&!0===C.isCluster||void 0!==I&&!0===I.isCluster||void 0!==C&&void 0!==I||t.push(e)})),this.remove(t,!1)}},{key:"_addMissingEdges",value:function(){var g=this.body.data.edges;if(null!=g){var t=this.body.edges,A=[];Sd(g).call(g,(function(g,e){void 0===t[e]&&A.push(e)})),this.add(A,!0)}}}]),g}(),Qx=function(){function g(t,A,e){Us(this,g),this.body=t,this.physicsBody=A,this.barnesHutTree,this.setOptions(e),this._rng=Tp("BARNES HUT SOLVER")}return Xs(g,[{key:"setOptions",value:function(g){this.options=g,this.thetaInversed=1/this.options.theta,this.overlapAvoidanceFactor=1-Math.max(0,Math.min(1,this.options.avoidOverlap))}},{key:"solve",value:function(){if(0!==this.options.gravitationalConstant&&this.physicsBody.physicsNodeIndices.length>0){var g,t=this.body.nodes,A=this.physicsBody.physicsNodeIndices,e=A.length,C=this._formBarnesHutTree(t,A);this.barnesHutTree=C;for(var I=0;I<e;I++)(g=t[A[I]]).options.mass>0&&this._getForceContributions(C.root,g)}}},{key:"_getForceContributions",value:function(g,t){this._getForceContribution(g.children.NW,t),this._getForceContribution(g.children.NE,t),this._getForceContribution(g.children.SW,t),this._getForceContribution(g.children.SE,t)}},{key:"_getForceContribution",value:function(g,t){if(g.childrenCount>0){var A=g.centerOfMass.x-t.x,e=g.centerOfMass.y-t.y,C=Math.sqrt(A*A+e*e);C*g.calcSize>this.thetaInversed?this._calculateForces(C,A,e,t,g):4===g.childrenCount?this._getForceContributions(g,t):g.children.data.id!=t.id&&this._calculateForces(C,A,e,t,g)}}},{key:"_calculateForces",value:function(g,t,A,e,C){0===g&&(t=g=.1),this.overlapAvoidanceFactor<1&&e.shape.radius&&(g=Math.max(.1+this.overlapAvoidanceFactor*e.shape.radius,g-e.shape.radius));var I=this.options.gravitationalConstant*C.mass*e.options.mass/Math.pow(g,3),i=t*I,o=A*I;this.physicsBody.forces[e.id].x+=i,this.physicsBody.forces[e.id].y+=o}},{key:"_formBarnesHutTree",value:function(g,t){for(var A,e=t.length,C=g[t[0]].x,I=g[t[0]].y,i=g[t[0]].x,o=g[t[0]].y,n=1;n<e;n++){var r=g[t[n]],s=r.x,a=r.y;r.options.mass>0&&(s<C&&(C=s),s>i&&(i=s),a<I&&(I=a),a>o&&(o=a))}var d=Math.abs(i-C)-Math.abs(o-I);d>0?(I-=.5*d,o+=.5*d):(C+=.5*d,i-=.5*d);var h=Math.max(1e-5,Math.abs(i-C)),l=.5*h,c=.5*(C+i),u=.5*(I+o),p={root:{centerOfMass:{x:0,y:0},mass:0,range:{minX:c-l,maxX:c+l,minY:u-l,maxY:u+l},size:h,calcSize:1/h,children:{data:null},maxWidth:0,level:0,childrenCount:4}};this._splitBranch(p.root);for(var f=0;f<e;f++)(A=g[t[f]]).options.mass>0&&this._placeInTree(p.root,A);return p}},{key:"_updateBranchMass",value:function(g,t){var A=g.centerOfMass,e=g.mass+t.options.mass,C=1/e;A.x=A.x*g.mass+t.x*t.options.mass,A.x*=C,A.y=A.y*g.mass+t.y*t.options.mass,A.y*=C,g.mass=e;var I=Math.max(Math.max(t.height,t.radius),t.width);g.maxWidth=g.maxWidth<I?I:g.maxWidth}},{key:"_placeInTree",value:function(g,t,A){1==A&&void 0!==A||this._updateBranchMass(g,t);var e,C=g.children.NW.range;e=C.maxX>t.x?C.maxY>t.y?"NW":"SW":C.maxY>t.y?"NE":"SE",this._placeInRegion(g,t,e)}},{key:"_placeInRegion",value:function(g,t,A){var e=g.children[A];switch(e.childrenCount){case 0:e.children.data=t,e.childrenCount=1,this._updateBranchMass(e,t);break;case 1:e.children.data.x===t.x&&e.children.data.y===t.y?(t.x+=this._rng(),t.y+=this._rng()):(this._splitBranch(e),this._placeInTree(e,t));break;case 4:this._placeInTree(e,t)}}},{key:"_splitBranch",value:function(g){var t=null;1===g.childrenCount&&(t=g.children.data,g.mass=0,g.centerOfMass.x=0,g.centerOfMass.y=0),g.childrenCount=4,g.children.data=null,this._insertRegion(g,"NW"),this._insertRegion(g,"NE"),this._insertRegion(g,"SW"),this._insertRegion(g,"SE"),null!=t&&this._placeInTree(g,t)}},{key:"_insertRegion",value:function(g,t){var A,e,C,I,i=.5*g.size;switch(t){case"NW":A=g.range.minX,e=g.range.minX+i,C=g.range.minY,I=g.range.minY+i;break;case"NE":A=g.range.minX+i,e=g.range.maxX,C=g.range.minY,I=g.range.minY+i;break;case"SW":A=g.range.minX,e=g.range.minX+i,C=g.range.minY+i,I=g.range.maxY;break;case"SE":A=g.range.minX+i,e=g.range.maxX,C=g.range.minY+i,I=g.range.maxY}g.children[t]={centerOfMass:{x:0,y:0},mass:0,range:{minX:A,maxX:e,minY:C,maxY:I},size:.5*g.size,calcSize:2*g.calcSize,children:{data:null},maxWidth:0,level:g.level+1,childrenCount:0}}},{key:"_debug",value:function(g,t){void 0!==this.barnesHutTree&&(g.lineWidth=1,this._drawBranch(this.barnesHutTree.root,g,t))}},{key:"_drawBranch",value:function(g,t,A){void 0===A&&(A="#FF0000"),4===g.childrenCount&&(this._drawBranch(g.children.NW,t),this._drawBranch(g.children.NE,t),this._drawBranch(g.children.SE,t),this._drawBranch(g.children.SW,t)),t.strokeStyle=A,t.beginPath(),t.moveTo(g.range.minX,g.range.minY),t.lineTo(g.range.maxX,g.range.minY),t.stroke(),t.beginPath(),t.moveTo(g.range.maxX,g.range.minY),t.lineTo(g.range.maxX,g.range.maxY),t.stroke(),t.beginPath(),t.moveTo(g.range.maxX,g.range.maxY),t.lineTo(g.range.minX,g.range.maxY),t.stroke(),t.beginPath(),t.moveTo(g.range.minX,g.range.maxY),t.lineTo(g.range.minX,g.range.minY),t.stroke()}}]),g}(),_x=function(){function g(t,A,e){Us(this,g),this._rng=Tp("REPULSION SOLVER"),this.body=t,this.physicsBody=A,this.setOptions(e)}return Xs(g,[{key:"setOptions",value:function(g){this.options=g}},{key:"solve",value:function(){for(var g,t,A,e,C,I,i,o,n=this.body.nodes,r=this.physicsBody.physicsNodeIndices,s=this.physicsBody.forces,a=this.options.nodeDistance,d=-2/3/a,h=0;h<r.length-1;h++){i=n[r[h]];for(var l=h+1;l<r.length;l++)g=(o=n[r[l]]).x-i.x,t=o.y-i.y,0===(A=Math.sqrt(g*g+t*t))&&(g=A=.1*this._rng()),A<2*a&&(I=A<.5*a?1:d*A+1.3333333333333333,e=g*(I/=A),C=t*I,s[i.id].x-=e,s[i.id].y-=C,s[o.id].x+=e,s[o.id].y+=C)}}}]),g}(),Ux=function(){function g(t,A,e){Us(this,g),this.body=t,this.physicsBody=A,this.setOptions(e)}return Xs(g,[{key:"setOptions",value:function(g){this.options=g,this.overlapAvoidanceFactor=Math.max(0,Math.min(1,this.options.avoidOverlap||0))}},{key:"solve",value:function(){for(var g=this.body.nodes,t=this.physicsBody.physicsNodeIndices,A=this.physicsBody.forces,e=this.options.nodeDistance,C=0;C<t.length-1;C++)for(var I=g[t[C]],i=C+1;i<t.length;i++){var o=g[t[i]];if(I.level===o.level){var n=e+this.overlapAvoidanceFactor*((I.shape.radius||0)/2+(o.shape.radius||0)/2),r=o.x-I.x,s=o.y-I.y,a=Math.sqrt(r*r+s*s),d=void 0;d=a<n?-Math.pow(.05*a,2)+Math.pow(.05*n,2):0,0!==a&&(d/=a);var h=r*d,l=s*d;A[I.id].x-=h,A[I.id].y-=l,A[o.id].x+=h,A[o.id].y+=l}}}}]),g}(),Hx=function(){function g(t,A,e){Us(this,g),this.body=t,this.physicsBody=A,this.setOptions(e)}return Xs(g,[{key:"setOptions",value:function(g){this.options=g}},{key:"solve",value:function(){for(var g,t,A,e,C,I=this.physicsBody.physicsEdgeIndices,i=this.body.edges,o=0;o<I.length;o++)!0===(t=i[I[o]]).connected&&t.toId!==t.fromId&&void 0!==this.body.nodes[t.toId]&&void 0!==this.body.nodes[t.fromId]&&(void 0!==t.edgeType.via?(g=void 0===t.options.length?this.options.springLength:t.options.length,A=t.to,e=t.edgeType.via,C=t.from,this._calculateSpringForce(A,e,.5*g),this._calculateSpringForce(e,C,.5*g)):(g=void 0===t.options.length?1.5*this.options.springLength:t.options.length,this._calculateSpringForce(t.from,t.to,g)))}},{key:"_calculateSpringForce",value:function(g,t,A){var e=g.x-t.x,C=g.y-t.y,I=Math.max(Math.sqrt(e*e+C*C),.01),i=this.options.springConstant*(A-I)/I,o=e*i,n=C*i;void 0!==this.physicsBody.forces[g.id]&&(this.physicsBody.forces[g.id].x+=o,this.physicsBody.forces[g.id].y+=n),void 0!==this.physicsBody.forces[t.id]&&(this.physicsBody.forces[t.id].x-=o,this.physicsBody.forces[t.id].y-=n)}}]),g}(),Kx=function(){function g(t,A,e){Us(this,g),this.body=t,this.physicsBody=A,this.setOptions(e)}return Xs(g,[{key:"setOptions",value:function(g){this.options=g}},{key:"solve",value:function(){for(var g,t,A,e,C,I,i,o,n,r,s=this.body.edges,a=.5,d=this.physicsBody.physicsEdgeIndices,h=this.physicsBody.physicsNodeIndices,l=this.physicsBody.forces,c=0;c<h.length;c++){var u=h[c];l[u].springFx=0,l[u].springFy=0}for(var p=0;p<d.length;p++)!0===(t=s[d[p]]).connected&&(g=void 0===t.options.length?this.options.springLength:t.options.length,A=t.from.x-t.to.x,e=t.from.y-t.to.y,o=0===(o=Math.sqrt(A*A+e*e))?.01:o,C=A*(i=this.options.springConstant*(g-o)/o),I=e*i,t.to.level!=t.from.level?(void 0!==l[t.toId]&&(l[t.toId].springFx-=C,l[t.toId].springFy-=I),void 0!==l[t.fromId]&&(l[t.fromId].springFx+=C,l[t.fromId].springFy+=I)):(void 0!==l[t.toId]&&(l[t.toId].x-=a*C,l[t.toId].y-=a*I),void 0!==l[t.fromId]&&(l[t.fromId].x+=a*C,l[t.fromId].y+=a*I)));i=1;for(var f=0;f<h.length;f++){var v=h[f];n=Math.min(i,Math.max(-i,l[v].springFx)),r=Math.min(i,Math.max(-i,l[v].springFy)),l[v].x+=n,l[v].y+=r}for(var y=0,m=0,b=0;b<h.length;b++){var w=h[b];y+=l[w].x,m+=l[w].y}for(var k=y/h.length,x=m/h.length,E=0;E<h.length;E++){var O=h[E];l[O].x-=k,l[O].y-=x}}}]),g}(),Xx=function(){function g(t,A,e){Us(this,g),this.body=t,this.physicsBody=A,this.setOptions(e)}return Xs(g,[{key:"setOptions",value:function(g){this.options=g}},{key:"solve",value:function(){for(var g,t,A,e,C=this.body.nodes,I=this.physicsBody.physicsNodeIndices,i=this.physicsBody.forces,o=0;o<I.length;o++){g=-(e=C[I[o]]).x,t=-e.y,A=Math.sqrt(g*g+t*t),this._calculateForces(A,g,t,i,e)}}},{key:"_calculateForces",value:function(g,t,A,e,C){var I=0===g?0:this.options.centralGravity/g;e[C.id].x=t*I,e[C.id].y=A*I}}]),g}();function Jx(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var qx=function(g){dm(A,g);var t=Jx(A);function A(g,e,C){var I;return Us(this,A),(I=t.call(this,g,e,C))._rng=Tp("FORCE ATLAS 2 BASED REPULSION SOLVER"),I}return Xs(A,[{key:"_calculateForces",value:function(g,t,A,e,C){0===g&&(t=g=.1*this._rng()),this.overlapAvoidanceFactor<1&&e.shape.radius&&(g=Math.max(.1+this.overlapAvoidanceFactor*e.shape.radius,g-e.shape.radius));var I=e.edges.length+1,i=this.options.gravitationalConstant*C.mass*e.options.mass*I/Math.pow(g,2),o=t*i,n=A*i;this.physicsBody.forces[e.id].x+=o,this.physicsBody.forces[e.id].y+=n}}]),A}(Qx);function $x(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var gE=function(g){dm(A,g);var t=$x(A);function A(g,e,C){return Us(this,A),t.call(this,g,e,C)}return Xs(A,[{key:"_calculateForces",value:function(g,t,A,e,C){if(g>0){var I=C.edges.length+1,i=this.options.centralGravity*I*C.options.mass;e[C.id].x=t*i,e[C.id].y=A*i}}}]),A}(Xx),tE=function(){function g(t){Us(this,g),this.body=t,this.physicsBody={physicsNodeIndices:[],physicsEdgeIndices:[],forces:{},velocities:{}},this.physicsEnabled=!0,this.simulationInterval=1e3/60,this.requiresTimeout=!0,this.previousStates={},this.referenceState={},this.freezeCache={},this.renderTimer=void 0,this.adaptiveTimestep=!1,this.adaptiveTimestepEnabled=!1,this.adaptiveCounter=0,this.adaptiveInterval=3,this.stabilized=!1,this.startedStabilization=!1,this.stabilizationIterations=0,this.ready=!1,this.options={},this.defaultOptions={enabled:!0,barnesHut:{theta:.5,gravitationalConstant:-2e3,centralGravity:.3,springLength:95,springConstant:.04,damping:.09,avoidOverlap:0},forceAtlas2Based:{theta:.5,gravitationalConstant:-50,centralGravity:.01,springConstant:.08,springLength:100,damping:.4,avoidOverlap:0},repulsion:{centralGravity:.2,springLength:200,springConstant:.05,nodeDistance:100,damping:.09,avoidOverlap:0},hierarchicalRepulsion:{centralGravity:0,springLength:100,springConstant:.01,nodeDistance:120,damping:.09},maxVelocity:50,minVelocity:.75,solver:"barnesHut",stabilization:{enabled:!0,iterations:1e3,updateInterval:50,onlyDynamicEdges:!1,fit:!0},timestep:.5,adaptiveTimestep:!0,wind:{x:0,y:0}},ae(this.options,this.defaultOptions),this.timestep=.5,this.layoutFailed=!1,this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g=this;this.body.emitter.on("initPhysics",(function(){g.initPhysics()})),this.body.emitter.on("_layoutFailed",(function(){g.layoutFailed=!0})),this.body.emitter.on("resetPhysics",(function(){g.stopSimulation(),g.ready=!1})),this.body.emitter.on("disablePhysics",(function(){g.physicsEnabled=!1,g.stopSimulation()})),this.body.emitter.on("restorePhysics",(function(){g.setOptions(g.options),!0===g.ready&&g.startSimulation()})),this.body.emitter.on("startSimulation",(function(){!0===g.ready&&g.startSimulation()})),this.body.emitter.on("stopSimulation",(function(){g.stopSimulation()})),this.body.emitter.on("destroy",(function(){g.stopSimulation(!1),g.body.emitter.off()})),this.body.emitter.on("_dataChanged",(function(){g.updatePhysicsData()}))}},{key:"setOptions",value:function(g){if(void 0!==g)if(!1===g)this.options.enabled=!1,this.physicsEnabled=!1,this.stopSimulation();else if(!0===g)this.options.enabled=!0,this.physicsEnabled=!0,this.startSimulation();else{this.physicsEnabled=!0,Yp(["stabilization"],this.options,g),of(this.options,g,"stabilization"),void 0===g.enabled&&(this.options.enabled=!0),!1===this.options.enabled&&(this.physicsEnabled=!1,this.stopSimulation());var t=this.options.wind;t&&(("number"!=typeof t.x||My(t.x))&&(t.x=0),("number"!=typeof t.y||My(t.y))&&(t.y=0)),this.timestep=this.options.timestep}this.init()}},{key:"init",value:function(){var g;"forceAtlas2Based"===this.options.solver?(g=this.options.forceAtlas2Based,this.nodesSolver=new qx(this.body,this.physicsBody,g),this.edgesSolver=new Hx(this.body,this.physicsBody,g),this.gravitySolver=new gE(this.body,this.physicsBody,g)):"repulsion"===this.options.solver?(g=this.options.repulsion,this.nodesSolver=new _x(this.body,this.physicsBody,g),this.edgesSolver=new Hx(this.body,this.physicsBody,g),this.gravitySolver=new Xx(this.body,this.physicsBody,g)):"hierarchicalRepulsion"===this.options.solver?(g=this.options.hierarchicalRepulsion,this.nodesSolver=new Ux(this.body,this.physicsBody,g),this.edgesSolver=new Kx(this.body,this.physicsBody,g),this.gravitySolver=new Xx(this.body,this.physicsBody,g)):(g=this.options.barnesHut,this.nodesSolver=new Qx(this.body,this.physicsBody,g),this.edgesSolver=new Hx(this.body,this.physicsBody,g),this.gravitySolver=new Xx(this.body,this.physicsBody,g)),this.modelOptions=g}},{key:"initPhysics",value:function(){!0===this.physicsEnabled&&!0===this.options.enabled?!0===this.options.stabilization.enabled?this.stabilize():(this.stabilized=!1,this.ready=!0,this.body.emitter.emit("fit",{},this.layoutFailed),this.startSimulation()):(this.ready=!0,this.body.emitter.emit("fit"))}},{key:"startSimulation",value:function(){var g;!0===this.physicsEnabled&&!0===this.options.enabled?(this.stabilized=!1,this.adaptiveTimestep=!1,this.body.emitter.emit("_resizeNodes"),void 0===this.viewFunction&&(this.viewFunction=Me(g=this.simulationStep).call(g,this),this.body.emitter.on("initRedraw",this.viewFunction),this.body.emitter.emit("_startRendering"))):this.body.emitter.emit("_redraw")}},{key:"stopSimulation",value:function(){var g=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];this.stabilized=!0,!0===g&&this._emitStabilized(),void 0!==this.viewFunction&&(this.body.emitter.off("initRedraw",this.viewFunction),this.viewFunction=void 0,!0===g&&this.body.emitter.emit("_stopRendering"))}},{key:"simulationStep",value:function(){var g=xd();this.physicsTick(),(xd()-g<.4*this.simulationInterval||!0===this.runDoubleSpeed)&&!1===this.stabilized&&(this.physicsTick(),this.runDoubleSpeed=!0),!0===this.stabilized&&this.stopSimulation()}},{key:"_emitStabilized",value:function(){var g=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.stabilizationIterations;(this.stabilizationIterations>1||!0===this.startedStabilization)&&yc((function(){g.body.emitter.emit("stabilized",{iterations:t}),g.startedStabilization=!1,g.stabilizationIterations=0}),0)}},{key:"physicsStep",value:function(){this.gravitySolver.solve(),this.nodesSolver.solve(),this.edgesSolver.solve(),this.moveNodes()}},{key:"adjustTimeStep",value:function(){!0===this._evaluateStepQuality()?this.timestep=1.2*this.timestep:this.timestep/1.2<this.options.timestep?this.timestep=this.options.timestep:(this.adaptiveCounter=-1,this.timestep=Math.max(this.options.timestep,this.timestep/1.2))}},{key:"physicsTick",value:function(){if(this._startStabilizing(),!0!==this.stabilized){if(!0===this.adaptiveTimestep&&!0===this.adaptiveTimestepEnabled)this.adaptiveCounter%this.adaptiveInterval==0?(this.timestep=2*this.timestep,this.physicsStep(),this.revert(),this.timestep=.5*this.timestep,this.physicsStep(),this.physicsStep(),this.adjustTimeStep()):this.physicsStep(),this.adaptiveCounter+=1;else this.timestep=this.options.timestep,this.physicsStep();!0===this.stabilized&&this.revert(),this.stabilizationIterations++}}},{key:"updatePhysicsData",value:function(){this.physicsBody.forces={},this.physicsBody.physicsNodeIndices=[],this.physicsBody.physicsEdgeIndices=[];var g=this.body.nodes,t=this.body.edges;for(var A in g)Object.prototype.hasOwnProperty.call(g,A)&&!0===g[A].options.physics&&this.physicsBody.physicsNodeIndices.push(g[A].id);for(var e in t)Object.prototype.hasOwnProperty.call(t,e)&&!0===t[e].options.physics&&this.physicsBody.physicsEdgeIndices.push(t[e].id);for(var C=0;C<this.physicsBody.physicsNodeIndices.length;C++){var I=this.physicsBody.physicsNodeIndices[C];this.physicsBody.forces[I]={x:0,y:0},void 0===this.physicsBody.velocities[I]&&(this.physicsBody.velocities[I]={x:0,y:0})}for(var i in this.physicsBody.velocities)void 0===g[i]&&delete this.physicsBody.velocities[i]}},{key:"revert",value:function(){var g=yd(this.previousStates),t=this.body.nodes,A=this.physicsBody.velocities;this.referenceState={};for(var e=0;e<g.length;e++){var C=g[e];void 0!==t[C]?!0===t[C].options.physics&&(this.referenceState[C]={positions:{x:t[C].x,y:t[C].y}},A[C].x=this.previousStates[C].vx,A[C].y=this.previousStates[C].vy,t[C].x=this.previousStates[C].x,t[C].y=this.previousStates[C].y):delete this.previousStates[C]}}},{key:"_evaluateStepQuality",value:function(){var g,t,A=this.body.nodes,e=this.referenceState;for(var C in this.referenceState)if(Object.prototype.hasOwnProperty.call(this.referenceState,C)&&void 0!==A[C]&&(g=A[C].x-e[C].positions.x,t=A[C].y-e[C].positions.y,Math.sqrt(Math.pow(g,2)+Math.pow(t,2))>.3))return!1;return!0}},{key:"moveNodes",value:function(){for(var g=this.physicsBody.physicsNodeIndices,t=0,A=0,e=0;e<g.length;e++){var C=g[e],I=this._performStep(C);t=Math.max(t,I),A+=I}this.adaptiveTimestepEnabled=A/g.length<5,this.stabilized=t<this.options.minVelocity}},{key:"calculateComponentVelocity",value:function(g,t,A){g+=(t-this.modelOptions.damping*g)/A*this.timestep;var e=this.options.maxVelocity||1e9;return Math.abs(g)>e&&(g=g>0?e:-e),g}},{key:"_performStep",value:function(g){var t=this.body.nodes[g],A=this.physicsBody.forces[g];this.options.wind&&(A.x+=this.options.wind.x,A.y+=this.options.wind.y);var e=this.physicsBody.velocities[g];return this.previousStates[g]={x:t.x,y:t.y,vx:e.x,vy:e.y},!1===t.options.fixed.x?(e.x=this.calculateComponentVelocity(e.x,A.x,t.options.mass),t.x+=e.x*this.timestep):(A.x=0,e.x=0),!1===t.options.fixed.y?(e.y=this.calculateComponentVelocity(e.y,A.y,t.options.mass),t.y+=e.y*this.timestep):(A.y=0,e.y=0),Math.sqrt(Math.pow(e.x,2)+Math.pow(e.y,2))}},{key:"_freezeNodes",value:function(){var g=this.body.nodes;for(var t in g)if(Object.prototype.hasOwnProperty.call(g,t)&&g[t].x&&g[t].y){var A=g[t].options.fixed;this.freezeCache[t]={x:A.x,y:A.y},A.x=!0,A.y=!0}}},{key:"_restoreFrozenNodes",value:function(){var g=this.body.nodes;for(var t in g)Object.prototype.hasOwnProperty.call(g,t)&&void 0!==this.freezeCache[t]&&(g[t].options.fixed.x=this.freezeCache[t].x,g[t].options.fixed.y=this.freezeCache[t].y);this.freezeCache={}}},{key:"stabilize",value:function(){var g=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.options.stabilization.iterations;"number"!=typeof t&&(t=this.options.stabilization.iterations,console.error("The stabilize method needs a numeric amount of iterations. Switching to default: ",t)),0!==this.physicsBody.physicsNodeIndices.length?(this.adaptiveTimestep=this.options.adaptiveTimestep,this.body.emitter.emit("_resizeNodes"),this.stopSimulation(),this.stabilized=!1,this.body.emitter.emit("_blockRedraw"),this.targetIterations=t,!0===this.options.stabilization.onlyDynamicEdges&&this._freezeNodes(),this.stabilizationIterations=0,yc((function(){return g._stabilizationBatch()}),0)):this.ready=!0}},{key:"_startStabilizing",value:function(){return!0!==this.startedStabilization&&(this.body.emitter.emit("startStabilizing"),this.startedStabilization=!0,!0)}},{key:"_stabilizationBatch",value:function(){var g=this,t=function(){return!1===g.stabilized&&g.stabilizationIterations<g.targetIterations},A=function(){g.body.emitter.emit("stabilizationProgress",{iterations:g.stabilizationIterations,total:g.targetIterations})};this._startStabilizing()&&A();for(var e,C=0;t()&&C<this.options.stabilization.updateInterval;)this.physicsTick(),C++;(A(),t())?yc(Me(e=this._stabilizationBatch).call(e,this),0):this._finalizeStabilization()}},{key:"_finalizeStabilization",value:function(){this.body.emitter.emit("_allowRedraw"),!0===this.options.stabilization.fit&&this.body.emitter.emit("fit"),!0===this.options.stabilization.onlyDynamicEdges&&this._restoreFrozenNodes(),this.body.emitter.emit("stabilizationIterationsDone"),this.body.emitter.emit("_requestRedraw"),!0===this.stabilized?this._emitStabilized():this.startSimulation(),this.ready=!0}},{key:"_drawForces",value:function(g){for(var t=0;t<this.physicsBody.physicsNodeIndices.length;t++){var A=this.physicsBody.physicsNodeIndices[t],e=this.body.nodes[A],C=this.physicsBody.forces[A],I=Math.sqrt(Math.pow(C.x,2)+Math.pow(C.x,2)),i=Math.min(Math.max(5,I),15),o=3*i,n=tf((180-180*Math.min(1,Math.max(0,.03*I)))/360,1,1),r={x:e.x+20*C.x,y:e.y+20*C.y};g.lineWidth=i,g.strokeStyle=n,g.beginPath(),g.moveTo(e.x,e.y),g.lineTo(r.x,r.y),g.stroke();var s=Math.atan2(C.y,C.x);g.fillStyle=n,xx.draw(g,{type:"arrow",point:r,angle:s,length:o}),Rc(g).call(g)}}}]),g}(),AE=function(){function g(){Us(this,g)}return Xs(g,null,[{key:"getRange",value:function(g){var t,A=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],e=1e9,C=-1e9,I=1e9,i=-1e9;if(A.length>0)for(var o=0;o<A.length;o++)I>(t=g[A[o]]).shape.boundingBox.left&&(I=t.shape.boundingBox.left),i<t.shape.boundingBox.right&&(i=t.shape.boundingBox.right),e>t.shape.boundingBox.top&&(e=t.shape.boundingBox.top),C<t.shape.boundingBox.bottom&&(C=t.shape.boundingBox.bottom);return 1e9===I&&-1e9===i&&1e9===e&&-1e9===C&&(e=0,C=0,I=0,i=0),{minX:I,maxX:i,minY:e,maxY:C}}},{key:"getRangeCore",value:function(g){var t,A=arguments.length>1&&void 0!==arguments[1]?arguments[1]:[],e=1e9,C=-1e9,I=1e9,i=-1e9;if(A.length>0)for(var o=0;o<A.length;o++)I>(t=g[A[o]]).x&&(I=t.x),i<t.x&&(i=t.x),e>t.y&&(e=t.y),C<t.y&&(C=t.y);return 1e9===I&&-1e9===i&&1e9===e&&-1e9===C&&(e=0,C=0,I=0,i=0),{minX:I,maxX:i,minY:e,maxY:C}}},{key:"findCenter",value:function(g){return{x:.5*(g.maxX+g.minX),y:.5*(g.maxY+g.minY)}}},{key:"cloneOptions",value:function(g,t){var A={};return void 0===t||"node"===t?(Wp(A,g.options,!0),A.x=g.x,A.y=g.y,A.amountOfConnections=g.edges.length):Wp(A,g.options,!0),A}}]),g}();function eE(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var CE=function(g){dm(A,g);var t=eE(A);function A(g,e,C,I,i,o){var n;return Us(this,A),(n=t.call(this,g,e,C,I,i,o)).isCluster=!0,n.containedNodes={},n.containedEdges={},n}return Xs(A,[{key:"_openChildCluster",value:function(g){var t=this,A=this.body.nodes[g];if(void 0===this.containedNodes[g])throw new Error("node with id: "+g+" not in current cluster");if(!A.isCluster)throw new Error("node with id: "+g+" is not a cluster");delete this.containedNodes[g],Hp(A.edges,(function(g){delete t.containedEdges[g.id]})),Hp(A.containedNodes,(function(g,A){t.containedNodes[A]=g})),A.containedNodes={},Hp(A.containedEdges,(function(g,A){t.containedEdges[A]=g})),A.containedEdges={},Hp(A.edges,(function(g){Hp(t.edges,(function(A){var e,C,I=zl(e=A.clusteringEdgeReplacingIds).call(e,g.id);-1!==I&&(Hp(g.clusteringEdgeReplacingIds,(function(g){A.clusteringEdgeReplacingIds.push(g),t.body.edges[g].edgeReplacedById=A.id})),hh(C=A.clusteringEdgeReplacingIds).call(C,I,1))}))})),A.edges=[]}}]),A}(Qk),IE=function(){function g(t){var A=this;Us(this,g),this.body=t,this.clusteredNodes={},this.clusteredEdges={},this.options={},this.defaultOptions={},ae(this.options,this.defaultOptions),this.body.emitter.on("_resetData",(function(){A.clusteredNodes={},A.clusteredEdges={}}))}return Xs(g,[{key:"clusterByHubsize",value:function(g,t){void 0===g?g=this._getHubSize():"object"===$a(g)&&(t=this._checkOptions(g),g=this._getHubSize());for(var A=[],e=0;e<this.body.nodeIndices.length;e++){var C=this.body.nodes[this.body.nodeIndices[e]];C.edges.length>=g&&A.push(C.id)}for(var I=0;I<A.length;I++)this.clusterByConnection(A[I],t,!0);this.body.emitter.emit("_dataChanged")}},{key:"cluster",value:function(){var g=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},A=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(void 0===t.joinCondition)throw new Error("Cannot call clusterByNodeData without a joinCondition function in the options.");t=this._checkOptions(t);var e={},C={};Hp(this.body.nodes,(function(A,I){A.options&&!0===t.joinCondition(A.options)&&(e[I]=A,Hp(A.edges,(function(t){void 0===g.clusteredEdges[t.id]&&(C[t.id]=t)})))})),this._cluster(e,C,t,A)}},{key:"clusterByEdgeCount",value:function(g,t){var A=this,e=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];t=this._checkOptions(t);for(var C,I,i,o=[],n={},r=function(e){var r={},s={},a=A.body.nodeIndices[e],d=A.body.nodes[a];if(void 0===n[a]){i=0,I=[];for(var h=0;h<d.edges.length;h++)C=d.edges[h],void 0===A.clusteredEdges[C.id]&&(C.toId!==C.fromId&&i++,I.push(C));if(i===g){for(var l=function(g){if(void 0===t.joinCondition||null===t.joinCondition)return!0;var A=AE.cloneOptions(g);return t.joinCondition(A)},c=!0,u=0;u<I.length;u++){C=I[u];var p=A._getConnectedId(C,a);if(!l(d)){c=!1;break}s[C.id]=C,r[a]=d,r[p]=A.body.nodes[p],n[a]=!0}if(yd(r).length>0&&yd(s).length>0&&!0===c){var f=function(){for(var g=0;g<o.length;++g)for(var t in r)if(void 0!==o[g].nodes[t])return o[g]}();if(void 0!==f){for(var v in r)void 0===f.nodes[v]&&(f.nodes[v]=r[v]);for(var y in s)void 0===f.edges[y]&&(f.edges[y]=s[y])}else o.push({nodes:r,edges:s})}}}},s=0;s<this.body.nodeIndices.length;s++)r(s);for(var a=0;a<o.length;a++)this._cluster(o[a].nodes,o[a].edges,t,!1);!0===e&&this.body.emitter.emit("_dataChanged")}},{key:"clusterOutliers",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];this.clusterByEdgeCount(1,g,t)}},{key:"clusterBridges",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];this.clusterByEdgeCount(2,g,t)}},{key:"clusterByConnection",value:function(g,t){var A,e=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];if(void 0===g)throw new Error("No nodeId supplied to clusterByConnection!");if(void 0===this.body.nodes[g])throw new Error("The nodeId given to clusterByConnection does not exist!");var C=this.body.nodes[g];void 0===(t=this._checkOptions(t,C)).clusterNodeProperties.x&&(t.clusterNodeProperties.x=C.x),void 0===t.clusterNodeProperties.y&&(t.clusterNodeProperties.y=C.y),void 0===t.clusterNodeProperties.fixed&&(t.clusterNodeProperties.fixed={},t.clusterNodeProperties.fixed.x=C.options.fixed.x,t.clusterNodeProperties.fixed.y=C.options.fixed.y);var I={},i={},o=C.id,n=AE.cloneOptions(C);I[o]=C;for(var r=0;r<C.edges.length;r++){var s=C.edges[r];if(void 0===this.clusteredEdges[s.id]){var a=this._getConnectedId(s,o);if(void 0===this.clusteredNodes[a])if(a!==o)if(void 0===t.joinCondition)i[s.id]=s,I[a]=this.body.nodes[a];else{var d=AE.cloneOptions(this.body.nodes[a]);!0===t.joinCondition(n,d)&&(i[s.id]=s,I[a]=this.body.nodes[a])}else i[s.id]=s}}var h=pd(A=yd(I)).call(A,(function(g){return I[g].id}));for(var l in I)if(Object.prototype.hasOwnProperty.call(I,l))for(var c=I[l],u=0;u<c.edges.length;u++){var p=c.edges[u];zl(h).call(h,this._getConnectedId(p,c.id))>-1&&(i[p.id]=p)}this._cluster(I,i,t,e)}},{key:"_createClusterEdges",value:function(g,t,A,e){for(var C,I,i,o,n,r,s=yd(g),a=[],d=0;d<s.length;d++){i=g[I=s[d]];for(var h=0;h<i.edges.length;h++)C=i.edges[h],void 0===this.clusteredEdges[C.id]&&(C.toId==C.fromId?t[C.id]=C:C.toId==I?(o=A.id,r=n=C.fromId):(o=C.toId,n=A.id,r=o),void 0===g[r]&&a.push({edge:C,fromId:n,toId:o}))}for(var l=[],c=function(g){for(var t=0;t<l.length;t++){var A=l[t],e=g.fromId===A.fromId&&g.toId===A.toId,C=g.fromId===A.toId&&g.toId===A.fromId;if(e||C)return A}return null},u=0;u<a.length;u++){var p=a[u],f=p.edge,v=c(p);null===v?(v=this._createClusteredEdge(p.fromId,p.toId,f,e),l.push(v)):v.clusteringEdgeReplacingIds.push(f.id),this.body.edges[f.id].edgeReplacedById=v.id,this._backupEdgeOptions(f),f.setOptions({physics:!1})}}},{key:"_checkOptions",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return void 0===g.clusterEdgeProperties&&(g.clusterEdgeProperties={}),void 0===g.clusterNodeProperties&&(g.clusterNodeProperties={}),g}},{key:"_cluster",value:function(g,t,A){var e=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],C=[];for(var I in g)Object.prototype.hasOwnProperty.call(g,I)&&void 0!==this.clusteredNodes[I]&&C.push(I);for(var i=0;i<C.length;++i)delete g[C[i]];if(0!=yd(g).length&&(1!=yd(g).length||1==A.clusterNodeProperties.allowSingleNodeCluster)){var o=Wp({},A.clusterNodeProperties);if(void 0!==A.processProperties){var n=[];for(var r in g)if(Object.prototype.hasOwnProperty.call(g,r)){var s=AE.cloneOptions(g[r]);n.push(s)}var a=[];for(var d in t)if(Object.prototype.hasOwnProperty.call(t,d)&&"clusterEdge:"!==d.substr(0,12)){var h=AE.cloneOptions(t[d],"edge");a.push(h)}if(!(o=A.processProperties(o,n,a)))throw new Error("The processProperties function does not return properties!")}void 0===o.id&&(o.id="cluster:"+cw());var l=o.id;void 0===o.label&&(o.label="cluster");var c=void 0;void 0===o.x&&(c=this._getClusterPosition(g),o.x=c.x),void 0===o.y&&(void 0===c&&(c=this._getClusterPosition(g)),o.y=c.y),o.id=l;var u=this.body.functions.createNode(o,CE);u.containedNodes=g,u.containedEdges=t,u.clusterEdgeProperties=A.clusterEdgeProperties,this.body.nodes[o.id]=u,this._clusterEdges(g,t,o,A.clusterEdgeProperties),o.id=void 0,!0===e&&this.body.emitter.emit("_dataChanged")}}},{key:"_backupEdgeOptions",value:function(g){void 0===this.clusteredEdges[g.id]&&(this.clusteredEdges[g.id]={physics:g.options.physics})}},{key:"_restoreEdge",value:function(g){var t=this.clusteredEdges[g.id];void 0!==t&&(g.setOptions({physics:t.physics}),delete this.clusteredEdges[g.id])}},{key:"isCluster",value:function(g){return void 0!==this.body.nodes[g]?!0===this.body.nodes[g].isCluster:(console.error("Node does not exist."),!1)}},{key:"_getClusterPosition",value:function(g){for(var t,A=yd(g),e=g[A[0]].x,C=g[A[0]].x,I=g[A[0]].y,i=g[A[0]].y,o=1;o<A.length;o++)e=(t=g[A[o]]).x<e?t.x:e,C=t.x>C?t.x:C,I=t.y<I?t.y:I,i=t.y>i?t.y:i;return{x:.5*(e+C),y:.5*(I+i)}}},{key:"openCluster",value:function(g,t){var A=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];if(void 0===g)throw new Error("No clusterNodeId supplied to openCluster.");var e=this.body.nodes[g];if(void 0===e)throw new Error("The clusterNodeId supplied to openCluster does not exist.");if(!0!==e.isCluster||void 0===e.containedNodes||void 0===e.containedEdges)throw new Error("The node:"+g+" is not a valid cluster.");var C=this.findNode(g),I=zl(C).call(C,g)-1;if(I>=0){var i=C[I],o=this.body.nodes[i];return o._openChildCluster(g),delete this.body.nodes[g],void(!0===A&&this.body.emitter.emit("_dataChanged"))}var n=e.containedNodes,r=e.containedEdges;if(void 0!==t&&void 0!==t.releaseFunction&&"function"==typeof t.releaseFunction){var s={},a={x:e.x,y:e.y};for(var d in n)if(Object.prototype.hasOwnProperty.call(n,d)){var h=this.body.nodes[d];s[d]={x:h.x,y:h.y}}var l=t.releaseFunction(a,s);for(var c in n)if(Object.prototype.hasOwnProperty.call(n,c)){var u=this.body.nodes[c];void 0!==l[c]&&(u.x=void 0===l[c].x?e.x:l[c].x,u.y=void 0===l[c].y?e.y:l[c].y)}}else Hp(n,(function(g){!1===g.options.fixed.x&&(g.x=e.x),!1===g.options.fixed.y&&(g.y=e.y)}));for(var p in n)if(Object.prototype.hasOwnProperty.call(n,p)){var f=this.body.nodes[p];f.vx=e.vx,f.vy=e.vy,f.setOptions({physics:!0}),delete this.clusteredNodes[p]}for(var v=[],y=0;y<e.edges.length;y++)v.push(e.edges[y]);for(var m=0;m<v.length;m++){for(var b=v[m],w=this._getConnectedId(b,g),k=this.clusteredNodes[w],x=0;x<b.clusteringEdgeReplacingIds.length;x++){var E=b.clusteringEdgeReplacingIds[x],O=this.body.edges[E];if(void 0!==O)if(void 0!==k){var T=this.body.nodes[k.clusterId];T.containedEdges[O.id]=O,delete r[O.id];var D=O.fromId,N=O.toId;O.toId==w?N=k.clusterId:D=k.clusterId,this._createClusteredEdge(D,N,O,T.clusterEdgeProperties,{hidden:!1,physics:!0})}else this._restoreEdge(O)}b.remove()}for(var R in r)Object.prototype.hasOwnProperty.call(r,R)&&this._restoreEdge(r[R]);delete this.body.nodes[g],!0===A&&this.body.emitter.emit("_dataChanged")}},{key:"getNodesInCluster",value:function(g){var t=[];if(!0===this.isCluster(g)){var A=this.body.nodes[g].containedNodes;for(var e in A)Object.prototype.hasOwnProperty.call(A,e)&&t.push(this.body.nodes[e].id)}return t}},{key:"findNode",value:function(g){for(var t,A=[],e=0;void 0!==this.clusteredNodes[g]&&e<100;){if(void 0===(t=this.body.nodes[g]))return[];A.push(t.id),g=this.clusteredNodes[g].clusterId,e++}return void 0===(t=this.body.nodes[g])?[]:(A.push(t.id),Ud(A).call(A),A)}},{key:"updateClusteredNode",value:function(g,t){if(void 0===g)throw new Error("No clusteredNodeId supplied to updateClusteredNode.");if(void 0===t)throw new Error("No newOptions supplied to updateClusteredNode.");if(void 0===this.body.nodes[g])throw new Error("The clusteredNodeId supplied to updateClusteredNode does not exist.");this.body.nodes[g].setOptions(t),this.body.emitter.emit("_dataChanged")}},{key:"updateEdge",value:function(g,t){if(void 0===g)throw new Error("No startEdgeId supplied to updateEdge.");if(void 0===t)throw new Error("No newOptions supplied to updateEdge.");if(void 0===this.body.edges[g])throw new Error("The startEdgeId supplied to updateEdge does not exist.");for(var A=this.getClusteredEdges(g),e=0;e<A.length;e++){this.body.edges[A[e]].setOptions(t)}this.body.emitter.emit("_dataChanged")}},{key:"getClusteredEdges",value:function(g){for(var t=[],A=0;void 0!==g&&void 0!==this.body.edges[g]&&A<100;)t.push(this.body.edges[g].id),g=this.body.edges[g].edgeReplacedById,A++;return Ud(t).call(t),t}},{key:"getBaseEdge",value:function(g){return this.getBaseEdges(g)[0]}},{key:"getBaseEdges",value:function(g){for(var t=[g],A=[],e=[],C=0;t.length>0&&C<100;){var I=t.pop();if(void 0!==I){var i=this.body.edges[I];if(void 0!==i){C++;var o=i.clusteringEdgeReplacingIds;if(void 0===o)e.push(I);else for(var n=0;n<o.length;++n){var r=o[n];-1===zl(t).call(t,o)&&-1===zl(A).call(A,o)&&t.push(r)}A.push(I)}}}return e}},{key:"_getConnectedId",value:function(g,t){return g.toId!=t?g.toId:(g.fromId,g.fromId)}},{key:"_getHubSize",value:function(){for(var g=0,t=0,A=0,e=0,C=0;C<this.body.nodeIndices.length;C++){var I=this.body.nodes[this.body.nodeIndices[C]];I.edges.length>e&&(e=I.edges.length),g+=I.edges.length,t+=Math.pow(I.edges.length,2),A+=1}g/=A;var i=(t/=A)-Math.pow(g,2),o=Math.sqrt(i),n=Math.floor(g+2*o);return n>e&&(n=e),n}},{key:"_createClusteredEdge",value:function(g,t,A,e,C){var I=AE.cloneOptions(A,"edge");Wp(I,e),I.from=g,I.to=t,I.id="clusterEdge:"+cw(),void 0!==C&&Wp(I,C);var i=this.body.functions.createEdge(I);return i.clusteringEdgeReplacingIds=[A.id],i.connect(),this.body.edges[i.id]=i,i}},{key:"_clusterEdges",value:function(g,t,A,e){if(t instanceof Yx){var C=t,I={};I[C.id]=C,t=I}if(g instanceof Qk){var i=g,o={};o[i.id]=i,g=o}if(null==A)throw new Error("_clusterEdges: parameter clusterNode required");for(var n in void 0===e&&(e=A.clusterEdgeProperties),this._createClusterEdges(g,t,A,e),t)if(Object.prototype.hasOwnProperty.call(t,n)&&void 0!==this.body.edges[n]){var r=this.body.edges[n];this._backupEdgeOptions(r),r.setOptions({physics:!1})}for(var s in g)Object.prototype.hasOwnProperty.call(g,s)&&(this.clusteredNodes[s]={clusterId:A.id,node:this.body.nodes[s]},this.body.nodes[s].setOptions({physics:!1}))}},{key:"_getClusterNodeForNode",value:function(g){if(void 0!==g){var t=this.clusteredNodes[g];if(void 0!==t){var A=t.clusterId;if(void 0!==A)return this.body.nodes[A]}}}},{key:"_filter",value:function(g,t){var A=[];return Hp(g,(function(g){t(g)&&A.push(g)})),A}},{key:"_updateState",value:function(){var g,t=this,A=[],e={},C=function(g){Hp(t.body.nodes,(function(t){!0===t.isCluster&&g(t)}))};for(g in this.clusteredNodes){if(Object.prototype.hasOwnProperty.call(this.clusteredNodes,g))void 0===this.body.nodes[g]&&A.push(g)}C((function(g){for(var t=0;t<A.length;t++)delete g.containedNodes[A[t]]}));for(var I=0;I<A.length;I++)delete this.clusteredNodes[A[I]];Hp(this.clusteredEdges,(function(g){var A=t.body.edges[g];void 0!==A&&A.endPointsValid()||(e[g]=g)})),C((function(g){Hp(g.containedEdges,(function(g,t){g.endPointsValid()||e[t]||(e[t]=t)}))})),Hp(this.body.edges,(function(g,A){var C=!0,I=g.clusteringEdgeReplacingIds;if(void 0!==I){var i=0;Hp(I,(function(g){var A=t.body.edges[g];void 0!==A&&A.endPointsValid()&&(i+=1)})),C=i>0}g.endPointsValid()&&C||(e[A]=A)})),C((function(g){Hp(e,(function(A){delete g.containedEdges[A],Hp(g.edges,(function(C,I){C.id!==A?C.clusteringEdgeReplacingIds=t._filter(C.clusteringEdgeReplacingIds,(function(g){return!e[g]})):g.edges[I]=null})),g.edges=t._filter(g.edges,(function(g){return null!==g}))}))})),Hp(e,(function(g){delete t.clusteredEdges[g]})),Hp(e,(function(g){delete t.body.edges[g]})),Hp(yd(this.body.edges),(function(g){var A=t.body.edges[g],e=t._isClusteredNode(A.fromId)||t._isClusteredNode(A.toId);if(e!==t._isClusteredEdge(A.id))if(e){var C=t._getClusterNodeForNode(A.fromId);void 0!==C&&t._clusterEdges(t.body.nodes[A.fromId],A,C);var I=t._getClusterNodeForNode(A.toId);void 0!==I&&t._clusterEdges(t.body.nodes[A.toId],A,I)}else delete t._clusterEdges[g],t._restoreEdge(A)}));for(var i=!1,o=!0,n=function(){var g=[];C((function(t){var A=yd(t.containedNodes).length,e=!0===t.options.allowSingleNodeCluster;(e&&A<1||!e&&A<2)&&g.push(t.id)}));for(var A=0;A<g.length;++A)t.openCluster(g[A],{},!1);o=g.length>0,i=i||o};o;)n();i&&this._updateState()}},{key:"_isClusteredNode",value:function(g){return void 0!==this.clusteredNodes[g]}},{key:"_isClusteredEdge",value:function(g){return void 0!==this.clusteredEdges[g]}}]),g}();function iE(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return oE(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return oE(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function oE(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}var nE=function(){function g(t,A){var e;Us(this,g),void 0!==window&&(e=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame),window.requestAnimationFrame=void 0===e?function(g){g()}:e,this.body=t,this.canvas=A,this.redrawRequested=!1,this.renderTimer=void 0,this.requiresTimeout=!0,this.renderingActive=!1,this.renderRequests=0,this.allowRedraw=!0,this.dragging=!1,this.zooming=!1,this.options={},this.defaultOptions={hideEdgesOnDrag:!1,hideEdgesOnZoom:!1,hideNodesOnDrag:!1},ae(this.options,this.defaultOptions),this._determineBrowserMethod(),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g,t=this;this.body.emitter.on("dragStart",(function(){t.dragging=!0})),this.body.emitter.on("dragEnd",(function(){t.dragging=!1})),this.body.emitter.on("zoom",(function(){t.zooming=!0,window.clearTimeout(t.zoomTimeoutId),t.zoomTimeoutId=yc((function(){var g;t.zooming=!1,Me(g=t._requestRedraw).call(g,t)()}),250)})),this.body.emitter.on("_resizeNodes",(function(){t._resizeNodes()})),this.body.emitter.on("_redraw",(function(){!1===t.renderingActive&&t._redraw()})),this.body.emitter.on("_blockRedraw",(function(){t.allowRedraw=!1})),this.body.emitter.on("_allowRedraw",(function(){t.allowRedraw=!0,t.redrawRequested=!1})),this.body.emitter.on("_requestRedraw",Me(g=this._requestRedraw).call(g,this)),this.body.emitter.on("_startRendering",(function(){t.renderRequests+=1,t.renderingActive=!0,t._startRendering()})),this.body.emitter.on("_stopRendering",(function(){t.renderRequests-=1,t.renderingActive=t.renderRequests>0,t.renderTimer=void 0})),this.body.emitter.on("destroy",(function(){t.renderRequests=0,t.allowRedraw=!1,t.renderingActive=!1,!0===t.requiresTimeout?clearTimeout(t.renderTimer):window.cancelAnimationFrame(t.renderTimer),t.body.emitter.off()}))}},{key:"setOptions",value:function(g){if(void 0!==g){Vp(["hideEdgesOnDrag","hideEdgesOnZoom","hideNodesOnDrag"],this.options,g)}}},{key:"_requestNextFrame",value:function(g,t){if("undefined"!=typeof window){var A,e=window;return!0===this.requiresTimeout?A=yc(g,t):e.requestAnimationFrame&&(A=e.requestAnimationFrame(g)),A}}},{key:"_startRendering",value:function(){var g;!0===this.renderingActive&&(void 0===this.renderTimer&&(this.renderTimer=this._requestNextFrame(Me(g=this._renderStep).call(g,this),this.simulationInterval)))}},{key:"_renderStep",value:function(){!0===this.renderingActive&&(this.renderTimer=void 0,!0===this.requiresTimeout&&this._startRendering(),this._redraw(),!1===this.requiresTimeout&&this._startRendering())}},{key:"redraw",value:function(){this.body.emitter.emit("setSize"),this._redraw()}},{key:"_requestRedraw",value:function(){var g=this;!0!==this.redrawRequested&&!1===this.renderingActive&&!0===this.allowRedraw&&(this.redrawRequested=!0,this._requestNextFrame((function(){g._redraw(!1)}),0))}},{key:"_redraw",value:function(){var g=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(!0===this.allowRedraw){this.body.emitter.emit("initRedraw"),this.redrawRequested=!1;var t={drawExternalLabels:null};0!==this.canvas.frame.canvas.width&&0!==this.canvas.frame.canvas.height||this.canvas.setSize(),this.canvas.setTransform();var A=this.canvas.getContext(),e=this.canvas.frame.canvas.clientWidth,C=this.canvas.frame.canvas.clientHeight;if(A.clearRect(0,0,e,C),0===this.canvas.frame.clientWidth)return;if(A.save(),A.translate(this.body.view.translation.x,this.body.view.translation.y),A.scale(this.body.view.scale,this.body.view.scale),A.beginPath(),this.body.emitter.emit("beforeDrawing",A),A.closePath(),!1===g&&(!1===this.dragging||!0===this.dragging&&!1===this.options.hideEdgesOnDrag)&&(!1===this.zooming||!0===this.zooming&&!1===this.options.hideEdgesOnZoom)&&this._drawEdges(A),!1===this.dragging||!0===this.dragging&&!1===this.options.hideNodesOnDrag){var I=this._drawNodes(A,g),i=I.drawExternalLabels;t.drawExternalLabels=i}!1===g&&(!1===this.dragging||!0===this.dragging&&!1===this.options.hideEdgesOnDrag)&&(!1===this.zooming||!0===this.zooming&&!1===this.options.hideEdgesOnZoom)&&this._drawArrows(A),null!=t.drawExternalLabels&&t.drawExternalLabels(),!1===g&&this._drawSelectionBox(A),A.beginPath(),this.body.emitter.emit("afterDrawing",A),A.closePath(),A.restore(),!0===g&&A.clearRect(0,0,e,C)}}},{key:"_resizeNodes",value:function(){this.canvas.setTransform();var g=this.canvas.getContext();g.save(),g.translate(this.body.view.translation.x,this.body.view.translation.y),g.scale(this.body.view.scale,this.body.view.scale);var t,A=this.body.nodes;for(var e in A)Object.prototype.hasOwnProperty.call(A,e)&&((t=A[e]).resize(g),t.updateBoundingBox(g,t.selected));g.restore()}},{key:"_drawNodes",value:function(g){for(var t,A,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],C=this.body.nodes,I=this.body.nodeIndices,i=[],o=[],n=20,r=this.canvas.DOMtoCanvas({x:-n,y:-n}),s=this.canvas.DOMtoCanvas({x:this.canvas.frame.canvas.clientWidth+n,y:this.canvas.frame.canvas.clientHeight+n}),a={top:r.y,left:r.x,bottom:s.y,right:s.x},d=[],h=0;h<I.length;h++)if((t=C[I[h]]).hover)o.push(I[h]);else if(t.isSelected())i.push(I[h]);else if(!0===e){var l=t.draw(g);null!=l.drawExternalLabel&&d.push(l.drawExternalLabel)}else if(!0===t.isBoundingBoxOverlappingWith(a)){var c=t.draw(g);null!=c.drawExternalLabel&&d.push(c.drawExternalLabel)}else t.updateBoundingBox(g,t.selected);var u=i.length,p=o.length;for(A=0;A<u;A++){var f=(t=C[i[A]]).draw(g);null!=f.drawExternalLabel&&d.push(f.drawExternalLabel)}for(A=0;A<p;A++){var v=(t=C[o[A]]).draw(g);null!=v.drawExternalLabel&&d.push(v.drawExternalLabel)}return{drawExternalLabels:function(){var g,t=iE(d);try{for(t.s();!(g=t.n()).done;){(0,g.value)()}}catch(g){t.e(g)}finally{t.f()}}}}},{key:"_drawEdges",value:function(g){for(var t=this.body.edges,A=this.body.edgeIndices,e=0;e<A.length;e++){var C=t[A[e]];!0===C.connected&&C.draw(g)}}},{key:"_drawArrows",value:function(g){for(var t=this.body.edges,A=this.body.edgeIndices,e=0;e<A.length;e++){var C=t[A[e]];!0===C.connected&&C.drawArrows(g)}}},{key:"_determineBrowserMethod",value:function(){if("undefined"!=typeof window){var g=navigator.userAgent.toLowerCase();this.requiresTimeout=!1,(-1!=zl(g).call(g,"msie 9.0")||-1!=zl(g).call(g,"safari")&&zl(g).call(g,"chrome")<=-1)&&(this.requiresTimeout=!0)}else this.requiresTimeout=!0}},{key:"_drawSelectionBox",value:function(g){if(this.body.selectionBox.show){g.beginPath();var t=this.body.selectionBox.position.end.x-this.body.selectionBox.position.start.x,A=this.body.selectionBox.position.end.y-this.body.selectionBox.position.start.y;g.rect(this.body.selectionBox.position.start.x,this.body.selectionBox.position.start.y,t,A),g.fillStyle="rgba(151, 194, 252, 0.2)",g.fillRect(this.body.selectionBox.position.start.x,this.body.selectionBox.position.start.y,t,A),g.strokeStyle="rgba(151, 194, 252, 1)",g.stroke()}else g.closePath()}}]),g}(),rE=U.setInterval;function sE(g,t){t.inputHandler=function(g){g.isFirst&&t(g)},g.on("hammer.input",t.inputHandler)}function aE(g,t){return t.inputHandler=function(g){g.isFinal&&t(g)},g.on("hammer.input",t.inputHandler)}var dE=function(){function g(t){Us(this,g),this.body=t,this.pixelRatio=1,this.cameraState={},this.initialized=!1,this.canvasViewCenter={},this._cleanupCallbacks=[],this.options={},this.defaultOptions={autoResize:!0,height:"100%",width:"100%"},ae(this.options,this.defaultOptions),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g,t=this;this.body.emitter.once("resize",(function(g){0!==g.width&&(t.body.view.translation.x=.5*g.width),0!==g.height&&(t.body.view.translation.y=.5*g.height)})),this.body.emitter.on("setSize",Me(g=this.setSize).call(g,this)),this.body.emitter.on("destroy",(function(){t.hammerFrame.destroy(),t.hammer.destroy(),t._cleanUp()}))}},{key:"setOptions",value:function(g){var t,A,e,C,I,i=this;if(void 0!==g){Vp(["width","height","autoResize"],this.options,g)}if(this._cleanUp(),!0===this.options.autoResize){var o;if(window.ResizeObserver){var n=new ResizeObserver((function(){!0===i.setSize()&&i.body.emitter.emit("_requestRedraw")})),r=this.frame;n.observe(r),this._cleanupCallbacks.push((function(){n.unobserve(r)}))}else{var s=rE((function(){!0===i.setSize()&&i.body.emitter.emit("_requestRedraw")}),1e3);this._cleanupCallbacks.push((function(){clearInterval(s)}))}var a=Me(o=this._onResize).call(o,this);t=window,A="resize",e=a,t.addEventListener?(void 0===C&&(C=!1),"mousewheel"===A&&zh(I=navigator.userAgent).call(I,"Firefox")&&(A="DOMMouseScroll"),t.addEventListener(A,e,C)):t.attachEvent("on"+A,e),this._cleanupCallbacks.push((function(){!function(g,t,A,e){var C;g.removeEventListener?(void 0===e&&(e=!1),"mousewheel"===t&&zh(C=navigator.userAgent).call(C,"Firefox")&&(t="DOMMouseScroll"),g.removeEventListener(t,A,e)):g.detachEvent("on"+t,A)}(window,"resize",a)}))}}},{key:"_cleanUp",value:function(){var g,t,A;Sd(g=Ud(t=hh(A=this._cleanupCallbacks).call(A,0)).call(t)).call(g,(function(g){try{g()}catch(g){console.error(g)}}))}},{key:"_onResize",value:function(){this.setSize(),this.body.emitter.emit("_redraw")}},{key:"_getCameraState",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.pixelRatio;!0===this.initialized&&(this.cameraState.previousWidth=this.frame.canvas.width/g,this.cameraState.previousHeight=this.frame.canvas.height/g,this.cameraState.scale=this.body.view.scale,this.cameraState.position=this.DOMtoCanvas({x:.5*this.frame.canvas.width/g,y:.5*this.frame.canvas.height/g}))}},{key:"_setCameraState",value:function(){if(void 0!==this.cameraState.scale&&0!==this.frame.canvas.clientWidth&&0!==this.frame.canvas.clientHeight&&0!==this.pixelRatio&&this.cameraState.previousWidth>0&&this.cameraState.previousHeight>0){var g=this.frame.canvas.width/this.pixelRatio/this.cameraState.previousWidth,t=this.frame.canvas.height/this.pixelRatio/this.cameraState.previousHeight,A=this.cameraState.scale;1!=g&&1!=t?A=.5*this.cameraState.scale*(g+t):1!=g?A=this.cameraState.scale*g:1!=t&&(A=this.cameraState.scale*t),this.body.view.scale=A;var e=this.DOMtoCanvas({x:.5*this.frame.canvas.clientWidth,y:.5*this.frame.canvas.clientHeight}),C={x:e.x-this.cameraState.position.x,y:e.y-this.cameraState.position.y};this.body.view.translation.x+=C.x*this.body.view.scale,this.body.view.translation.y+=C.y*this.body.view.scale}}},{key:"_prepareValue",value:function(g){if("number"==typeof g)return g+"px";if("string"==typeof g){if(-1!==zl(g).call(g,"%")||-1!==zl(g).call(g,"px"))return g;if(-1===zl(g).call(g,"%"))return g+"px"}throw new Error("Could not use the value supplied for width or height:"+g)}},{key:"_create",value:function(){for(;this.body.container.hasChildNodes();)this.body.container.removeChild(this.body.container.firstChild);if(this.frame=document.createElement("div"),this.frame.className="vis-network",this.frame.style.position="relative",this.frame.style.overflow="hidden",this.frame.tabIndex=0,this.frame.canvas=document.createElement("canvas"),this.frame.canvas.style.position="relative",this.frame.appendChild(this.frame.canvas),this.frame.canvas.getContext)this._setPixelRatio(),this.setTransform();else{var g=document.createElement("DIV");g.style.color="red",g.style.fontWeight="bold",g.style.padding="10px",g.innerText="Error: your browser does not support HTML canvas",this.frame.canvas.appendChild(g)}this.body.container.appendChild(this.frame),this.body.view.scale=1,this.body.view.translation={x:.5*this.frame.canvas.clientWidth,y:.5*this.frame.canvas.clientHeight},this._bindHammer()}},{key:"_bindHammer",value:function(){var g=this;void 0!==this.hammer&&this.hammer.destroy(),this.drag={},this.pinch={},this.hammer=new mf(this.frame.canvas),this.hammer.get("pinch").set({enable:!0}),this.hammer.get("pan").set({threshold:5,direction:mf.DIRECTION_ALL}),sE(this.hammer,(function(t){g.body.eventListeners.onTouch(t)})),this.hammer.on("tap",(function(t){g.body.eventListeners.onTap(t)})),this.hammer.on("doubletap",(function(t){g.body.eventListeners.onDoubleTap(t)})),this.hammer.on("press",(function(t){g.body.eventListeners.onHold(t)})),this.hammer.on("panstart",(function(t){g.body.eventListeners.onDragStart(t)})),this.hammer.on("panmove",(function(t){g.body.eventListeners.onDrag(t)})),this.hammer.on("panend",(function(t){g.body.eventListeners.onDragEnd(t)})),this.hammer.on("pinch",(function(t){g.body.eventListeners.onPinch(t)})),this.frame.canvas.addEventListener("wheel",(function(t){g.body.eventListeners.onMouseWheel(t)})),this.frame.canvas.addEventListener("mousemove",(function(t){g.body.eventListeners.onMouseMove(t)})),this.frame.canvas.addEventListener("contextmenu",(function(t){g.body.eventListeners.onContext(t)})),this.hammerFrame=new mf(this.frame),aE(this.hammerFrame,(function(t){g.body.eventListeners.onRelease(t)}))}},{key:"setSize",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:this.options.width,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.options.height;g=this._prepareValue(g),t=this._prepareValue(t);var A=!1,e=this.frame.canvas.width,C=this.frame.canvas.height,I=this.pixelRatio;if(this._setPixelRatio(),g!=this.options.width||t!=this.options.height||this.frame.style.width!=g||this.frame.style.height!=t)this._getCameraState(I),this.frame.style.width=g,this.frame.style.height=t,this.frame.canvas.style.width="100%",this.frame.canvas.style.height="100%",this.frame.canvas.width=Math.round(this.frame.canvas.clientWidth*this.pixelRatio),this.frame.canvas.height=Math.round(this.frame.canvas.clientHeight*this.pixelRatio),this.options.width=g,this.options.height=t,this.canvasViewCenter={x:.5*this.frame.clientWidth,y:.5*this.frame.clientHeight},A=!0;else{var i=Math.round(this.frame.canvas.clientWidth*this.pixelRatio),o=Math.round(this.frame.canvas.clientHeight*this.pixelRatio);this.frame.canvas.width===i&&this.frame.canvas.height===o||this._getCameraState(I),this.frame.canvas.width!==i&&(this.frame.canvas.width=i,A=!0),this.frame.canvas.height!==o&&(this.frame.canvas.height=o,A=!0)}return!0===A&&(this.body.emitter.emit("resize",{width:Math.round(this.frame.canvas.width/this.pixelRatio),height:Math.round(this.frame.canvas.height/this.pixelRatio),oldWidth:Math.round(e/this.pixelRatio),oldHeight:Math.round(C/this.pixelRatio)}),this._setCameraState()),this.initialized=!0,A}},{key:"getContext",value:function(){return this.frame.canvas.getContext("2d")}},{key:"_determinePixelRatio",value:function(){var g=this.getContext();if(void 0===g)throw new Error("Could not get canvax context");var t=1;return"undefined"!=typeof window&&(t=window.devicePixelRatio||1),t/(g.webkitBackingStorePixelRatio||g.mozBackingStorePixelRatio||g.msBackingStorePixelRatio||g.oBackingStorePixelRatio||g.backingStorePixelRatio||1)}},{key:"_setPixelRatio",value:function(){this.pixelRatio=this._determinePixelRatio()}},{key:"setTransform",value:function(){var g=this.getContext();if(void 0===g)throw new Error("Could not get canvax context");g.setTransform(this.pixelRatio,0,0,this.pixelRatio,0,0)}},{key:"_XconvertDOMtoCanvas",value:function(g){return(g-this.body.view.translation.x)/this.body.view.scale}},{key:"_XconvertCanvasToDOM",value:function(g){return g*this.body.view.scale+this.body.view.translation.x}},{key:"_YconvertDOMtoCanvas",value:function(g){return(g-this.body.view.translation.y)/this.body.view.scale}},{key:"_YconvertCanvasToDOM",value:function(g){return g*this.body.view.scale+this.body.view.translation.y}},{key:"canvasToDOM",value:function(g){return{x:this._XconvertCanvasToDOM(g.x),y:this._YconvertCanvasToDOM(g.y)}}},{key:"DOMtoCanvas",value:function(g){return{x:this._XconvertDOMtoCanvas(g.x),y:this._YconvertDOMtoCanvas(g.y)}}}]),g}();function hE(g,t){var A=ae({nodes:t,minZoomLevel:Number.MIN_VALUE,maxZoomLevel:1},null!=g?g:{});if(!sd(A.nodes))throw new TypeError("Nodes has to be an array of ids.");if(0===A.nodes.length&&(A.nodes=t),!("number"==typeof A.minZoomLevel&&A.minZoomLevel>0))throw new TypeError("Min zoom level has to be a number higher than zero.");if(!("number"==typeof A.maxZoomLevel&&A.minZoomLevel<=A.maxZoomLevel))throw new TypeError("Max zoom level has to be a number higher than min zoom level.");return A}var lE=function(){function g(t,A){var e,C,I=this;Us(this,g),this.body=t,this.canvas=A,this.animationSpeed=1/this.renderRefreshRate,this.animationEasingFunction="easeInOutQuint",this.easingTime=0,this.sourceScale=0,this.targetScale=0,this.sourceTranslation=0,this.targetTranslation=0,this.lockedOnNodeId=void 0,this.lockedOnNodeOffset=void 0,this.touchTime=0,this.viewFunction=void 0,this.body.emitter.on("fit",Me(e=this.fit).call(e,this)),this.body.emitter.on("animationFinished",(function(){I.body.emitter.emit("_stopRendering")})),this.body.emitter.on("unlockNode",Me(C=this.releaseNode).call(C,this))}return Xs(g,[{key:"setOptions",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};this.options=g}},{key:"fit",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];g=hE(g,this.body.nodeIndices);var A,e,C=this.canvas.frame.canvas.clientWidth,I=this.canvas.frame.canvas.clientHeight;if(0===C||0===I)e=1,A=AE.getRange(this.body.nodes,g.nodes);else if(!0===t){var i=0;for(var o in this.body.nodes)if(Object.prototype.hasOwnProperty.call(this.body.nodes,o)){var n=this.body.nodes[o];!0===n.predefinedPosition&&(i+=1)}if(i>.5*this.body.nodeIndices.length)return void this.fit(g,!1);A=AE.getRange(this.body.nodes,g.nodes);var r=this.body.nodeIndices.length;e=12.662/(r+7.4147)+.0964822;var s=Math.min(C/600,I/600);e*=s}else{this.body.emitter.emit("_resizeNodes"),A=AE.getRange(this.body.nodes,g.nodes);var a=1.1*Math.abs(A.maxX-A.minX),d=1.1*Math.abs(A.maxY-A.minY),h=C/a,l=I/d;e=h<=l?h:l}e>g.maxZoomLevel?e=g.maxZoomLevel:e<g.minZoomLevel&&(e=g.minZoomLevel);var c=AE.findCenter(A),u={position:c,scale:e,animation:g.animation};this.moveTo(u)}},{key:"focus",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(void 0!==this.body.nodes[g]){var A={x:this.body.nodes[g].x,y:this.body.nodes[g].y};t.position=A,t.lockedOnNode=g,this.moveTo(t)}else console.error("Node: "+g+" cannot be found.")}},{key:"moveTo",value:function(g){if(void 0!==g){if(null!=g.offset){if(null!=g.offset.x){if(g.offset.x=+g.offset.x,!Zy(g.offset.x))throw new TypeError('The option "offset.x" has to be a finite number.')}else g.offset.x=0;if(null!=g.offset.y){if(g.offset.y=+g.offset.y,!Zy(g.offset.y))throw new TypeError('The option "offset.y" has to be a finite number.')}else g.offset.x=0}else g.offset={x:0,y:0};if(null!=g.position){if(null!=g.position.x){if(g.position.x=+g.position.x,!Zy(g.position.x))throw new TypeError('The option "position.x" has to be a finite number.')}else g.position.x=0;if(null!=g.position.y){if(g.position.y=+g.position.y,!Zy(g.position.y))throw new TypeError('The option "position.y" has to be a finite number.')}else g.position.x=0}else g.position=this.getViewPosition();if(null!=g.scale){if(g.scale=+g.scale,!(g.scale>0))throw new TypeError('The option "scale" has to be a number greater than zero.')}else g.scale=this.body.view.scale;void 0===g.animation&&(g.animation={duration:0}),!1===g.animation&&(g.animation={duration:0}),!0===g.animation&&(g.animation={}),void 0===g.animation.duration&&(g.animation.duration=1e3),void 0===g.animation.easingFunction&&(g.animation.easingFunction="easeInOutQuad"),this.animateView(g)}else g={}}},{key:"animateView",value:function(g){if(void 0!==g){this.animationEasingFunction=g.animation.easingFunction,this.releaseNode(),!0===g.locked&&(this.lockedOnNodeId=g.lockedOnNode,this.lockedOnNodeOffset=g.offset),0!=this.easingTime&&this._transitionRedraw(!0),this.sourceScale=this.body.view.scale,this.sourceTranslation=this.body.view.translation,this.targetScale=g.scale,this.body.view.scale=this.targetScale;var t,A,e=this.canvas.DOMtoCanvas({x:.5*this.canvas.frame.canvas.clientWidth,y:.5*this.canvas.frame.canvas.clientHeight}),C=e.x-g.position.x,I=e.y-g.position.y;if(this.targetTranslation={x:this.sourceTranslation.x+C*this.targetScale+g.offset.x,y:this.sourceTranslation.y+I*this.targetScale+g.offset.y},0===g.animation.duration)if(null!=this.lockedOnNodeId)this.viewFunction=Me(t=this._lockedRedraw).call(t,this),this.body.emitter.on("initRedraw",this.viewFunction);else this.body.view.scale=this.targetScale,this.body.view.translation=this.targetTranslation,this.body.emitter.emit("_requestRedraw");else this.animationSpeed=1/(60*g.animation.duration*.001)||1/60,this.animationEasingFunction=g.animation.easingFunction,this.viewFunction=Me(A=this._transitionRedraw).call(A,this),this.body.emitter.on("initRedraw",this.viewFunction),this.body.emitter.emit("_startRendering")}}},{key:"_lockedRedraw",value:function(){var g=this.body.nodes[this.lockedOnNodeId].x,t=this.body.nodes[this.lockedOnNodeId].y,A=this.canvas.DOMtoCanvas({x:.5*this.canvas.frame.canvas.clientWidth,y:.5*this.canvas.frame.canvas.clientHeight}),e=A.x-g,C=A.y-t,I=this.body.view.translation,i={x:I.x+e*this.body.view.scale+this.lockedOnNodeOffset.x,y:I.y+C*this.body.view.scale+this.lockedOnNodeOffset.y};this.body.view.translation=i}},{key:"releaseNode",value:function(){void 0!==this.lockedOnNodeId&&void 0!==this.viewFunction&&(this.body.emitter.off("initRedraw",this.viewFunction),this.lockedOnNodeId=void 0,this.lockedOnNodeOffset=void 0)}},{key:"_transitionRedraw",value:function(){var g=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.easingTime+=this.animationSpeed,this.easingTime=!0===g?1:this.easingTime;var t=nf[this.animationEasingFunction](this.easingTime);if(this.body.view.scale=this.sourceScale+(this.targetScale-this.sourceScale)*t,this.body.view.translation={x:this.sourceTranslation.x+(this.targetTranslation.x-this.sourceTranslation.x)*t,y:this.sourceTranslation.y+(this.targetTranslation.y-this.sourceTranslation.y)*t},this.easingTime>=1){var A;if(this.body.emitter.off("initRedraw",this.viewFunction),this.easingTime=0,null!=this.lockedOnNodeId)this.viewFunction=Me(A=this._lockedRedraw).call(A,this),this.body.emitter.on("initRedraw",this.viewFunction);this.body.emitter.emit("animationFinished")}}},{key:"getScale",value:function(){return this.body.view.scale}},{key:"getViewPosition",value:function(){return this.canvas.DOMtoCanvas({x:.5*this.canvas.frame.canvas.clientWidth,y:.5*this.canvas.frame.canvas.clientHeight})}}]),g}();function cE(g){var t,A=g&&g.preventDefault||!1,e=g&&g.container||window,C={},I={keydown:{},keyup:{}},i={};for(t=97;t<=122;t++)i[String.fromCharCode(t)]={code:t-97+65,shift:!1};for(t=65;t<=90;t++)i[String.fromCharCode(t)]={code:t,shift:!0};for(t=0;t<=9;t++)i[""+t]={code:48+t,shift:!1};for(t=1;t<=12;t++)i["F"+t]={code:111+t,shift:!1};for(t=0;t<=9;t++)i["num"+t]={code:96+t,shift:!1};i["num*"]={code:106,shift:!1},i["num+"]={code:107,shift:!1},i["num-"]={code:109,shift:!1},i["num/"]={code:111,shift:!1},i["num."]={code:110,shift:!1},i.left={code:37,shift:!1},i.up={code:38,shift:!1},i.right={code:39,shift:!1},i.down={code:40,shift:!1},i.space={code:32,shift:!1},i.enter={code:13,shift:!1},i.shift={code:16,shift:void 0},i.esc={code:27,shift:!1},i.backspace={code:8,shift:!1},i.tab={code:9,shift:!1},i.ctrl={code:17,shift:!1},i.alt={code:18,shift:!1},i.delete={code:46,shift:!1},i.pageup={code:33,shift:!1},i.pagedown={code:34,shift:!1},i["="]={code:187,shift:!1},i["-"]={code:189,shift:!1},i["]"]={code:221,shift:!1},i["["]={code:219,shift:!1};var o=function(g){r(g,"keydown")},n=function(g){r(g,"keyup")},r=function(g,t){if(void 0!==I[t][g.keyCode]){for(var e=I[t][g.keyCode],C=0;C<e.length;C++)(void 0===e[C].shift||1==e[C].shift&&1==g.shiftKey||0==e[C].shift&&0==g.shiftKey)&&e[C].fn(g);1==A&&g.preventDefault()}};return C.bind=function(g,t,A){if(void 0===A&&(A="keydown"),void 0===i[g])throw new Error("unsupported key: "+g);void 0===I[A][i[g].code]&&(I[A][i[g].code]=[]),I[A][i[g].code].push({fn:t,shift:i[g].shift})},C.bindAll=function(g,t){for(var A in void 0===t&&(t="keydown"),i)i.hasOwnProperty(A)&&C.bind(A,g,t)},C.getKey=function(g){for(var t in i)if(i.hasOwnProperty(t)){if(1==g.shiftKey&&1==i[t].shift&&g.keyCode==i[t].code)return t;if(0==g.shiftKey&&0==i[t].shift&&g.keyCode==i[t].code)return t;if(g.keyCode==i[t].code&&"shift"==t)return t}return"unknown key, currently not supported"},C.unbind=function(g,t,A){if(void 0===A&&(A="keydown"),void 0===i[g])throw new Error("unsupported key: "+g);if(void 0!==t){var e=[],C=I[A][i[g].code];if(void 0!==C)for(var o=0;o<C.length;o++)C[o].fn==t&&C[o].shift==i[g].shift||e.push(I[A][i[g].code][o]);I[A][i[g].code]=e}else I[A][i[g].code]=[]},C.reset=function(){I={keydown:{},keyup:{}}},C.destroy=function(){I={keydown:{},keyup:{}},e.removeEventListener("keydown",o,!0),e.removeEventListener("keyup",n,!0)},e.addEventListener("keydown",o,!0),e.addEventListener("keyup",n,!0),C}Le('div.vis-network div.vis-navigation div.vis-button{-webkit-touch-callout:none;background-position:2px 2px;background-repeat:no-repeat;-moz-border-radius:17px;border-radius:17px;cursor:pointer;display:inline-block;height:34px;position:absolute;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;width:34px}div.vis-network div.vis-navigation div.vis-button:hover{box-shadow:0 0 3px 3px rgba(56,207,21,.3)}div.vis-network div.vis-navigation div.vis-button:active{box-shadow:0 0 1px 3px rgba(56,207,21,.95)}div.vis-network div.vis-navigation div.vis-button.vis-up{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABphJREFUeNqcV2twU9cR/nbPlVTHxpKRbNnBLyEbPyJisLEcPwgwUMKQtjNJAzNJZkgNNJOmJaZAaDKlxaXDTIBAcJtOOzSYKSkdiimhAdIMjyT4bYgBYxA2BgcUQPLrCiGDR4qt2x+yXTASFt1/957d7zt3z3d39xDCMQWUfgAz/RI/T4pSTAJpAGL8rECAXX7QFQGq9wOHOxYO1oCgjAdJj1wtB095Giv9TFuZAIWHAziATMPhTAwiHgUkYPXFJu92lMP/2MTpB1AKUCVEgNAcleUo1M+2F8TO6crSTncb1QleAOj2OTSX3Ge1p+Va42m5JrnzbnsCE8Ov+EHgpa0LPLvCJjZ/whuIlN8wAcXG+e1LUn9hm238QU84p1Ld83nsXvuO7Lq+LzKYGAT6/dn58m/HJTYf4O3EShkT8Irpzab1Uz9sGevT5+tWn+j6NB4A5hp/5NSr43xjfd5rW5tT9e3OAhCBiCua5/WsDEls/hdvYklZSwDefmrT8eXmtzuDkb5YZ33p9ndylICAVjWxf39xw/5g5Luv/9H84ZWNcwNEypZT87rXjqyJB85UYDMJYN3U7UdLJ6/6JlgqV517teRqf9uTlug8e1zEk27HgD22o98WsTBh8fWxvjm6ApdONbGvse8LM5NUPOm1Cfabuz3nACAgxX0QEFTJAnjNvLJ+Sepb14KRHnN+Ev+1XJOhZs3Qu1mbG97J2NQgsXroa1dtxrGuf8cHi1mUtPTay0lv1DMJSCRVLtoX+FgGgDQNysBAcez89l9nbbsQSji7rlXkEhjPxb/QatHOcFu0M9zz419oFSRhj/3PuaHiyqasv1Con9NGxHAYUsoCxAqImbYSgCWmFbZQwdsur7N0eC4m6tT6/jUZ750Zeb82c+OZGLWh/2p/W+Kfrmy0hIp/aVKpTSIJEqu2QgFx2iE8CwDp0RbH7Ljng/4yXr+XT3QdyhYsodS0slGr0g2OrEUK7eCrKW82SqzCVz3/yfb6vRwM4xn9rN7JkRkOQRLmfJn2LBPxQjDBqp9lD7XbX7X8pKTP160zR2bdeiX5jYeU/nLSTztNkem3XL5eXbltRUkonBxdgZ2IIUmahUxERQSCVT+rK5hzQ89xQ6P8VaaK1f5VmRvqQ4G+lba+nlnlb5brMhvlk7FBiaPzuwQEmEQhg5BOxMjWTncHc2501cQLkjDTsMCWpyuRQxFP0xXIJfp5FyVW4Zy7KajC06ItbiIGg6ZITBxDxIgbrr1jTSM0fibGIHz8O9sKK0GAibEua9spANh4aY2VmcEg+DEkiBgR/L2hYFgGtcErkQQAMVJgBxyy9hboZzv32v+Kpr7qbEECTAIMAoaJa3qPTmNiiAAgJAjk6J5xhu6HDAIgQYGLmI29PocmMcI8MNYvT1ckfzD9H/ub5br4e4Me9WfOKqtyX6Ud2cwC449PRamifDm6Auc0rTXokci+Xo1EAgBckiDuYGLjpTvntcGIA+SFcp6uUAaAI879VhWrRteYAqn/edq758brXJ1327QMhgJcZjA3EBjNrgZjOG1PkAjyTGENMjZPq5ECQ0MDE9ERBqFZrk0OJ3i4x/7vyIjBxGERt3takgVJEAp9xq3f769WiPDNvSsJdT3HDOEASPelmoBRYT3Kzt5uMtwauJEgSOCpwrk1DIJCoNUMwj9v7MweP9XSQ8/hJPp496fZTAICvLqcyv2B7nRbrgCA03JN5h8ub7A8VqpB437xHvsOy3l3cyaB4L2uqxhti1WLMcSgZQCw7+bOooO3Pk4JBZIYYXISMV5sKH59UePM10GESRGpIf/bE92HU452HywSJIGIllctrhp6YAK5+fHds0lLtJFMXNwkV6fFqA29mROefqiMJj1h6um4a5vY/92dKGaBxIhU5zJTWW2cJmEgGOmeb3c8FxAfb9mdf2RzyGGv5MvU7QwuEySwKHFp/c/M71zA/2F7b1RajnYdLAqMukMVu2YcfmDYE2MD7H+7/Xlq6cRIJqm4zXM+qd3TGjVBir43KSLlXjiELe5TsX+3/yW/ST45PaAHbKmccWh12AP93JNZywj0kSABIobpiXRHjtZ6faout2tyZMadGLXBCxBcvl6NfaAz+tKdFmObpzWl2+tIIBACYy0t/yj34M7HvsKUK+CGassvicX7alYDwwq+vykIEqPVa+Q9gdYk5+V+UE7lj3+FGbuBM/X5JUT8QwIVSSSZiTgmoFR2MfiqYFFPfjpkyrfWPopwxP47AP1pK1g9/dqeAAAAAElFTkSuQmCC");bottom:50px;left:55px}div.vis-network div.vis-navigation div.vis-button.vis-down{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABpdJREFUeNqcV21QlNcVfp5zX9ikoAvLEsAIIgsoHwpqWAQUNKLNaNv8iZ1JMkNG6/Qj/dDUyCSTtCHpmEkwVk3TToZRMjXj5MOG2KidjIkxQYSAQUAtX6IgIN8su8KCoOzbH4sk4q5g77/33uee555z7rnneYmZDB2MKcJKlyYbqOsZVIgGEOgSHQoy4AKbFFjqAo5dWn/rNAh9OpO852oeJHYxtrmEu4WALhMbxG2ZE9uFAlImDRLY/t/y0b3Ig+u+iWOKsAlgIZSb0OIf15kWtKo1NXh1d5xxiSPEN2wUAHrGOg11jirjWVtJyFnb6YgrzoYwocClu0DI5guPDb43Y2LLp/Iaqf9JCGSErGvIifxd7aqQn/TOJCvFvZ8Hf9haEH+m/6sFQgHBv1Sts/15WmJLkeyl6FuFwFPzny1/ZdE7Nfg/xhv1uUmH2w6kggQp+yqze7d5JbZ8Im+KpucSwI6EN7/cYtlxZarBCts3ptfrtq9odjaGKihE+sV0vRC3u8RqWmmbij149W+Wd5p2rnET6bsqsntyb6+pO3KqkE8FvLxo74lNUX9s9uTJb8/9fG2L81KoogJFYfCm3b9usNq0MXxzw1RsUkDqQICPqf/b/q8sQi3j4WdmtV47OFgNAO6r+DEUFAtFAc9YtpXmRP6hxVsI24cvhyoqnFtrK6jM7isgBa3Dl0O94TeGb255MvzXpUIFjVrhxo/dzgoARBuwFQJkBK9reCnurxfvXX8CRW3yW1G749vT2Br7ysW0oNX1pKDTPG+rm1gHRbibAHLm/7522sKnQCZqFgCUaBCqaS/bEw9vqtWoQROf3dBBiT6KTACImZ3YueqhDdOWjDbFQ4IzIl4elNUX5begU1HD6lPRmULKeghhDcpqnUmZuD3+nkgTH6gZEE9ctlZSoGmG9UIynSCsQVndMyX+IZGiBoHMjHh2SreCglClaSBiSEG8cYnD24bv7CWms/3FocO3hnw13plTggAFb196NdlPM44tC0zrSg5ItXmyEz070UEKCMRqQgkkBQ9NvL2eSJ+revoJTORSpoT6do4/7/7UShBFHQexM+HdfyUHWO8iN/uaRzX3/QjUSLlnqM72F4cCRIY5u9Zf+Y+BAv4AvzpkQ7WAIBRujA/7Vg6cia9xlId6InafVEAAGnQMUCSkb6zTMPdBy8hU3JjrphIq+CrD+Mvxeyumrr+4IH9y7o2GF5eDghuuGx4L2zbWZ9Dc0RoQRbkkFNRdP2/0BH7EtLJLKCjr+zqh2l5u8haZ847vTBW24kRFQXKAtcsT5oqz3igQENIoECkjBJUDZSGewBlBj/ammjLrdX1c/t70ero34gMte9IByLLAjPrUwKweT5jawQshdIuGMiF5XEBU2koivBl9NeEfJeYHwuxtI81zPrn2z6ip60c6DkV1jLTOCTaE2HNjd5Z4s9MwWBOhqEHp/I9cWDtUrJNoHm4KO9P7hdnTBoMYXI8Gb6gVCg63FS53jg9O5tA57tSOdHywnCAygrJrfcTgUe5U2cvNHSPtYYoKCWlrTgsIneB2AfFR+4F4b6f9ZdTzF6P8Ytud407/dy/nL7k9X9i8J9l5y+Ef6RfbnjPvWa8N5suez+KFCgqyPY95Lnd3stv2AcBZ2+mFbze+lui1xc3dXCUUlPafXNx4/aKxcajWWNp/MklRw8/mPFntbd+h1oLE847KhQQxejVg36QQqD0MPTzHv42Ux+uGasJNBnPfwllJd71kkX7RQ3WDNf7dox3BLcNNs6vt34bbbvYHJhlTGp6O+JVHb0/2HJtX1PH+aqECqG/5YN1nlXcokGvvO6vCc4x+QskotxVHB/qa+xbOWuzw8NB3nuo+Ht0z2hHsuGU3GrWAoZfi3jrxgHpw3BPpobaCH7vbqOw6mHI836vYW3Eqcq9AtioqbJy7ufQ3lhfu8sR+s9+3vL8klACsQSu7AnxMY1MxH7YXJp7oPpLulrrj+9575Ni2aeVt1teWfEWfHQLCaspseHzOU7VWU+aM5G2NoyL4i+6j8XWDNQsmGsKu/cv+nTtjQb/mm7hfENyvqEAK5v8opjPJaL26KGBpd5TfguuBvuZRgBgY6zO0jlyZXXe9JqR+8MK8ntHOMHfHIkhu2b/0yIH7/oXJ0yFlxYnPUdRbvuILgO7+y+91l6Ka6M+cnCf4fMSypXvymHf/vzBTD3CuNGUFKT8lmK5Rs5ASqKiBlAGBXFaiSuni0fkp1pJ7Ed4e/xsAqLk46EWsG1EAAAAASUVORK5CYII=");bottom:10px;left:55px}div.vis-network div.vis-navigation div.vis-button.vis-left{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABt5JREFUeNqsl2lUlOcVx//3Pi9DZRsGBgYiS2RYBQKIjAhEJW4pNrXNMbZpWtTGNkttYmJMG5soSZckRk+0p+dYPYY0Gk0ihlhRj63GhVUgBhDD5oIOy8AAMwzD4lCYtx+GqCQKuNyP7/Pc+3u2+7/3JUzEZFBYLh62S7yIZDmVBEIBqOwsQ4DNdtBFASq2A4cuZAwVgCCPF5LGHM0Chz+E1XamzUyAzCMO7IhMI+5MDCK+HpCANd+U2rYgC/Y7BoflYgVA2RAOoNYtyjDTe45+hk96e5QywaJR+NsAwDhocK61VCjLTYWaclNB0OW+en8mhl22g8C/rn7U+uGEwdov+C0i+Q0mIFWzoD7zwVU1czQ/6pjIreR3HPX5VL9jalHXiQgmBoH+XLHAtH5csDaXtxDLLzIBv5jyfOmG2H9U4S7snbpX43KaPpgBIhDx1rPzOlbfPC5GQT/nd1mS1zABa6PfPf5y5F/rcJeWpp7fPkly6f7KXBRCoOSATFfXll19x74HDsvFCghsJAG8HrvlvytCXm7EPVqc5wyzp5NX15muE1omKXXyMnd9yy5r5Q3wPghvJzrLAlimXV38+7D1DbhPFq1M6O4b6rPVWKsCBfHi5EWWv9TkQBYAEPpLvERMC9N8FtRvjt9dPl6wwo5jPvuas7WV5jNqEjz8wA+CBsaan+w9x1hrrXJtuaZX97ooLfqPLCUEGRR+iOwAsF2X98Uc30W3fb02u41frVqeVmo6FUkkwCAwCWxJ2Ls/0TPFNBb8TNdp9WvnVz4OAKdmX2QOzcMsAAjziDGMBd3asCF6SXHyknJTfqQTK+zpvhnVKT5zawCgzFTgN94pJXvP7gxxjTAIkpB+MnSWRMQZYEDnPVt/K4ejbZ/77726Lb6h95tAAiPELaJ1bcTbRfGeM8xv1azWSeyEa0P9igk+Nr1+oNFfkpwzJCJKIQA679ntN08yDXYo3qh+LuUrc0E4EcNL4dP7VNDzpU8FP3vpekoQQ5CEw4bPdEfa9+sAgEZUmkmAAAS5hLQ9p11XGO+pM8V5JLUfMeQARDMlEMKIGFOVCZYb0C7Fz0oeXmIZ6nZzYoV9od/jVS+GbahUOnn9b7T6sEOviUGyA8bMDlUa0W79wBW/bZf+lrY98cDBUI8YCxGDgHCJiVVEDN8R7QWAE8Z/+1mGut2i3eP1r0S+XRztkdBzq6NbF7WpbF3UprKxjvfHxbrfttla/QBArVDbJJIAQCURMRg8ugrKIAKBSNxzHtN3VdmxY0iQYSZmTeegwTlgknYAAB7RZBh2Nm7urbeeC1r19ROT52kWn3shfH2Fu1AO3RxjY/0fdac7/hPPJMDE11GC+HpBJmIEuAS3Oa6w01lybMbMgvgCE6O255zy24DeCr/Bvckn9+u8ZjXYIYvjxoMJy8oeXZrT9GHIqMWTwA2oI6cFMeDIcAiSEOyibXsmZG0hAFzuq1OyY6xBAnMJgdPOmks08zU/bbsB9x18P37PqS/b8+o/a96ZcLm3PmBH46Z5x40HW1eFvl4Uq0w0MwiCBOb7/qTsd6GvVY537DXWas1Iw1AiNJnOgwJi+bXhAbE08OnvaXSIW0TvYw88eaF/uM/WNdju3m5r9TlhPBzVNNDoPGC/5tRma/GJ80xqjPPUjVuvP2narrMOWd1Jlv/E1fN782UiNPZf9C/qOKa+ndOz2j+cz046sn+6KrVOsODirpOxld0lUxmEBK/ktvGgFd2l6taBZn9BAtEz5xYIvAn4/8rFKkgstAyZ6Yf+S67ezlkiSU73XXRV6xqh93TyssR4JF75efBvymLdE03jgT/Wb5tutLWpGbTm7wHZxQQAT+yDuKLyHRIk4cnAZ4pfCF9/HvfR9uh3xBxtz00BANsVDylnac6wAICaHMiBmW5NRLy4trcq0MtZ3RnpHme5H9AvjYeCc1t3pzMJgOSVnyw4eHZUB9Kyu68iMFPpysSppab8UJVC3Rnp/pDlXqF7mnYsdKQbv7cr6fDGW/Zczbt6jgUtV6kIlFxuyg/tH+6zJXmlGe8G+mlzdsyB1j3pTAwZ9q3/Sspbc9tmDwD0H3UffXCFlyuTlFpnPRdYb612c5c8+idPCu6fCLDKUubzsf6fSaWm0wmO9hbvZU8fDR2zoZ97OuppAu0UJEDEmOISZohT6q7Gek5rD3GN6FEp1DaAYB7sdNYPXPao7anS1Fmrg402g7+jYhGIaOXOaQc+uONfmCwZXJIf8xKx2KRgxYgOS+CROuyoyQKCxIhkOr4T6JWgxGnvZ1HWnf/CfHcBXxcnpRHxYwRKkUjSErFKkAQiNjP4kmBRTHbKm5KkKxwL+K39fwDX1XGF8ct++QAAAABJRU5ErkJggg==");bottom:10px;left:15px}div.vis-network div.vis-navigation div.vis-button.vis-right{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABs1JREFUeNqsl3tQlOcVxp9z3m+XygK7C4sLxkW5o4CAkYssFSkRjabjJEOSJm1IbZx2krapiZdeprW0NVVJ0pqMM0kYJQlqkoZImGioE1ItiCAgIsFwE4Es99vCslwChf36xy5EW1A0Pn9+73fO772e93kJC5EMCszFd20SbyFZNpJAAACtjWUI8KAN1CRAJTbg9LXNU+dBkG+Xkm7Zmg4OWoUdNqZXmQCZHQFsz0yOcCYGEc8mJGDnl2UTh5AO2x2DA3OxDaAsCDvQ32VF11qP9aZYz6SeFeooi17pPQEAvZNdTnWWKnWFuVhfYT7v0zza4M3EsMk2EPgnNZusby8Y7P8x/5lI/gMTYNSnNKQt/0Xtev1DfQtZlaK+M54fmDJXXhg4G8zEINBfqlLMe28L9s/lQ8Tyr5iAJ32fK/tj+OFq3IUO1O+JyGk7GgsiEPFrlQ/07bixXdwEPckHWZJ3MgG7Qw9+/mLIS/W4SyXoNvQskpyHLg1e8CNQ3NI0laoje7Tg/8CBudgGgQwSwO/DD322ze/FFnxLRWhiBzUK94GLA2f9mSTjfU+7mjqyrVe+AX8I4aGgShbA0/47Sn4ZuLcR90ih6qih0anRiVprtUEQb43bYtlXmwNZAEDAj/ACMW1M8ExpeDXyWMVCEl4yF7vntR/zLeov8JJlWfZR+Y3N92+cx/reOmu1quNrk27EWW0xvWspJcigoNNkA4C3Yk59vH7xltvu3ktDxe7PX34ilQCQfeci1j2xfn94ZrGCneY8uxcHCnW/vbr9EQD4d2ITc8AprAOAQLewroVAAaB8oMiLiRHvmVy7znNTjWCFrXKoJOSHFQ+kvnF9f+jco07s91MFdwmSkHQuYB0T8WYwIcYj0bTQdRufGlFKJMFVaCb/GvZW6aGI4yeXOwd2mr/u05zsyDY+W5X64Nm+fO85NpuJiCFJTpslIoonADEeiT2zIzIXuh+o25PQNtbsNVMOBUn2g08MiSTHN3uZjNTEDr4dnX/6H+1H/XPasmKvW+sMGfW/MXzende4K3h/ibvSYxIAItyie/K7cgCitQxCIBFjpTrKMgM+WPfrhLbxFi9iMQtlYjAJSCSBSYBAIPBNI3p86TPXj8bk56R4PVylFE626uFLQc9efiTVPDmgBIAAtzALEYNBQRITa4kYix21FwBax655CVagPLk7806Pj1qo/7MraF/FQ14/aMhszYhvGqn3KTef89rklWrSKXUTkn3mtJK9Bzf3XJA0e/PcrdgxIwSCDPmbZMQgABJkDBKzvn+yy2npIv9xAPB1Ceo2jTZ7Gc8afipIgEhAkACDwcSQQZBIIGnx5it7gg+U3wgcnbZKR1r+FnW+v2DVtDwtXCXNSKz797oAwDzZ7ySRAIBBFsTXmBh1w1+oZ4J3h+wv9lUFdbMDOrO+5IAqWIGZthuV13nC77nKRx8r7PssyibLIkoT1/h65HsfzWyu5tF6NYNB4EYJzKUETqgcLNVv0D/cDQBrNAnm9+LOfTLfNB5u2hf5z+6TMexYji+tVdrM5leMbWOtSwQx/F1C2rcuebIqwSO568a4WmuN3mEYSiUi+pRl2l1pLvYBsKArUKVwnZRYgdHpMWVG4+/WXhwoDBXE7OmkHzJ6JNemLfv51bniGqzVPoIkyLbpfK7ZMFIkE6FlrMn7Ql+BbiHg+zXGbgLjylDpyosD58KZmKM0cfWHI9//aD5o1VCZrnO83VuQQOja5PMCfwK8n3K2ChIbLVOD9KB36le3A+u/s2Q81C2yRavQmQNdVnamLnmq4nHD9jpB0rwm77jpjTW9E906Bu18fWlWCQHAox9CtGoXTwmS8IThZyXPB+29inuoE6bMsDM9ufEAMNHqJuU8ljMtAKA2B7IhzaWNiLfWjVQb3J10/SGuEZZ7Af1X7+lluZ3HkpgEQPL291M+qbzJgXQcG60ypKlVTGwsMxcFaJW6/hDXVZZvCz3RlrmRiQHwy9nRn2bM6bnas4cLfH6s1RIorsJcFDA2PToR7Z7QezfQD9qzwvI6TyTZC47ttXeiT+2c1+wBgOndoTPLt7mrmCRjvfULQ4O1xsVVchu7b9GysYUAqy3lnsdNb0aXmQuj7PYWL2etuRl6S0OfXLjiGQIdEY6K5esc2BWhjvkqXLO6x08VPKxV6iYAwuBkv5NpvNmtbrhaX2+tWdY70eVNINhtLW0/sjrv6B0/YdJlcGlR2AvE4hUlKwHQ7BU5cz8LRx0HaPY7gXb53L/67+mUfudPmP/twOWS6AQi/j6B4iWS/IlYK+yGYJDB1wWLErLRKd/omOJbAWf03wEAyO9m+/TtS3AAAAAASUVORK5CYII=");bottom:10px;left:95px}div.vis-network div.vis-navigation div.vis-button.vis-zoomIn{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABiBJREFUeNqkV2tQlOcVfp7zvgvDRe66y8htXUBR1GoFI+BtFJvRtjPJBGeaH2a8DGmbttgSTWbSJEw6TWOsrbbpTIeJZGqaTipTa6LJZDTVUTYQdNAohoso6qLucnERN0Axcb/8+HaJUHDX9Pz6vnnPe57vXJ5zzkeEIwaYcwBL/VrW0TCKqZANINEvBhSk3w9eUmC9HzjcsfarOhBGKJN84GkVJHcetvqFu4SAIYELYlpm4LpQQMqoQQKVnzeO7EYV/A8NnHMAGwHWQJmAjtg895LkFa7FU1d258UvGLBGpI4AQM9dd2TrwNn4016n9bS3LqNzsD1VKPAbfhCyqflR31thAzv+La+QxotCoNi6pn1D1s9aVli/3xtOVk72fjT1XVf17E9uHZspFBD8zdk13pdCAjsOyG6KUSEEnrT/tPHluW+cw7eQ19q2z6/t2rsYJEjZ07S6d+ukwI5/yQ7RxnYC2DZnx8dbHNs6xxs85T2R9GprZcmVwYs2BYWsmBzP83m7nIVJS73jdfdd+7PjjUu/XWUCGTtPre7ZHjxTY3Kq8DoV8Ou5u49snPGrKxN58syZ9aVXBztsigoUBd+Xt2NbfZ8llaVvah+vOz9hcX+CJenWp7eOOYS6ePpTU1w39vk+AwCzFPdDQbFGFPCUY2v9hqxfXJ0shNeHLtsUFc6UequbVvdVkwLX0GXbZPpl6Zuu/ij9x/VCBU1dU7bfdFYAIDsSFRCgeOqa9hfy/nDhwfwTKOrRd0U95n0iqch9+cKS5JVtpMCdkllhAhugCHcRwAb7z1tCEp8CCXAWAJRoCFXIYnti+sYWTQ0tll0wQMk+hGUAkBOX714xbV1IyuhxHhIMC/iR5OV9M2JmuhU1Vh7PXiakrIUQhcnLXeHQxPT4GyAtFqgwgAPF5iIFWkeu1SSLCKAweXn3/ZR5rXV7SddQpy3YDoNems9qTI5hGCitm1MOAAx0aaFCerTd84zjBed3Egq9ADA/rqD7Q3ctQC4REDmkYHb8goGgsR2tz5V0DV+xUdQoqAQ81RybU4IgFWgACgpaLLCIBUo0bv63y/aXy6+WBHWz4/IHSIGAuVooiaRgWqD3AsDVoQ6bEgtOrfJUhwrf0WUtk+r8sL6wvHvk5ijVUiJSRrQZuURtfoGMuaCoRyfP/yMy0XykgAA0DPRTxNp31x2ZFuUYBgB7bK7HNdhpKz6WXq6oQCooKghMKhkgji77vBoA1jkXlAvVfRQjFMUcmxSkRWd6gpjeu32R2kxTvyhKh1DQeud8fFBh26zfOe0xuR4JgAbzywCoRSzfeDUKatJKUQK+CjKiHZ6nZ2xzBnU7B9vixTy7qCHSQEhJU3+DtdT6mAcAFiWUeP/xyPH3Jwrfo3XzysemRcEA8F5RY8h6aPE1WwMLQ4OQ/EBANHmdGWHlzZyxk3ayB0m771yGooYy+KE0l35x0iBxZehS6ie9R1PCMaDvCzWDXA4hZ283ptwcvp6qqDBnyao6AWEQrBQQ/7y+d3YoA+NBTAaElo973p8tVFCQyipW+c3pdNu7BwBOe+tm/eniK/kPFWowpMfvuKrzzw80zSKIkWsJe0bHYu163BNwMwDsv7G36ODNtzMnM5IWZfeQgscbisvLPl1aDhLTo7I8k+n/p+dw5pGeg0WKGiS31K6vvTdmA7nx9uDZ9A3xMUIpbvSezE6MSOmbNWXewHhD6dH23o7BlqQvvrwTK6KQFpXl2WyvcE6LTB2eCPSdrurvmcUnO/cVfPD6pMteyfGs3QKpUFQoS9tU/xPH8xe+Tdd693pN/pHug0Xmqntvz1uLDo9Z9v5nnrn+dvujrI1JMUJd3OY7n97ua46douOGpkdlDoUDeG7g1NS/u/5a0Og9scCsB+ysWXSoMuyFftWJvM0E31SBjmWPznHPjy+8NjdhYfeMmJl3EiNSRgCi/25fpGu4M671zjlrm685s2fEnUoQ5lrLLW8uPLj3oX9hqgxIw8n8X1LU7yMkItCHzREZrGQV6ONmy5TggHk247sL/1jFqof/hRn/AWfqC0pI+QHBIk3tICXRrFTpF8hlJaqefh6yFxQ6HwQYlK8HAKyt3WsWxl7fAAAAAElFTkSuQmCC");bottom:10px;right:15px}div.vis-network div.vis-navigation div.vis-button.vis-zoomOut{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABV5JREFUeNq0l2tQVVUYht/3W/vACMr16IFRQDiAgChpgiikMqY1WjnN9KsfGOXYTOVgkvbDUsZuXrK0qZmGUSvNspjI8TZOmo6AGBoZYly8YB6Qw80DBwQ6jJ3dj30OZZmiwvtv77XW96y91l7v9y1iMNLBuCI84tZkIXU9gwqxAILdokNBOtzgJQWWuYEDFxfcLAGh3y0k79iaD4mfjOVu4WYhoItngBiR6RkuFJAyEJBA3m/lri3Ih/uewXFFyAG4A8oAWkcm2meEzrFNH53Vkhg4xWnxCXcBQGu/3bfGeTbwjKPUcsZRElnfUxcuFLh1Nwh5vurx7s8GDbZ+L+tI/U0hkGGZX5c9/pXqOZYn2gazK8Vth0fvsRUknbx+bIJQQPCts/Mda+4KthbJFoqeKwSejX6pfO2kjytxH1pfuyqlsGH7dJAgZWvFo23L/9muboF+JxtE0/OEwMqJG46uSHinFvepTPO8lhGaX+fPHSdjCKaPy/b3v7az58h/wHFFyIHCRirgjUlbfsiJWXEFD6iUoOkdQaaQ6z9dP2YVahljF4+yXdvZ/evf4G+hQk2sEAUsti4vWxa35gKGSBMDp3T23OxxVXdXRijKovSFzrerC6ELAMT6IhcCZIyeX7c68YPzGGLlxq89PyM0q5YU2M1RuQAg0EERbiaA7Ohl1RgmPTM2p1qjBk1Mm6GDErsfswAgLiDZPmfMwrbhAqeHzm6P8Z9gV9SQdTx2lpCyAEKkhc62YZiVEjTdRgo0zXeBRnImAaSFzm7xdjjtOBGyvmZVZkNvfZjXDhU14+BToFEDKRAQpAJ0HRTjP6XHpYUKEX7RzS9bV5c+FJTmAICUgNSWQ/ZCgJwhIOJIQVLgFKcXvKHm9cyGvithFDUAFQqECho1CBUIggYapAJ1QEFBExNMYoISDU1/NIR9cvndTG/c2IBkp2fC8ZpQgknBGI/3AsDvvRfDlJhwem5zwYMs7VNlaUtbXE1h3mezj9mlGSsXrBkzkFsGKGoDmedBJLfLjxQQgAYdHRSxtPfbfceNsPYBQPTI+GZbT31YxrGIpYoKpIKigkAgFOggNBrbQBBCBaEM2L+iGGmTgnF+Uc1epqO/3VejAoAOUZSLQkFN17lAb4eVCe+VRvvHN4sH6t1feqAmMUGoPHvvhdLzTjzfKoj0sza/GLOy1Bu3vqc20Pgl5YIGkVOEZFZ0nLLMszzdDADTgjIdX6Uf3zfUx6m6u8riKRhOCcmDAqLCURo53Oe4rrsyUlGD0nlIqubdKNZJXOm9FH6y7Yh5uKBnO8vNTX2N4YoKE2fMLREQOsE8AfFN4/ak4QIfbd2XJFRQkLx85ruN7NTp2AoAZxwlCR9dWJc81NDdtoLkc86KBIJwXQ3aOpCPqwuhR2SPbCBlUc2NyogQX3N7wqgU51BAf2w9EFXUtCtLqADqS76ev6/ilgrk2q6esxHZgf5CySh3FMcG+5jbE0ZNdj4odHdDwWPGcZNNO1MPbrxtzdW4s+tI5HPBwQTTzziKY3v/7HGlhmS23g90T+OO5L1Nu7MMw3Fv/Tx1f97/FnsAYPui8/D4nBB/oZZR230uoq67auQoLaB37Iio3sEAK52nR39p+zS13HFiilHeYtOOabdC71jQzz2R+ALBbcrjWNF+cfaUwLSrk4KmtsT4T+gK9jG7AKKjv93X1lcfUNNVaantropqddnDCcIoa7lk29S92+/5CpOvQ04VJ79KUe/7iI/Hh40U6c3PyuPjhmWKN8G8Fvnw1A/zmX/vV5h/T+CXstRMUp4kOFOjZiUlWBkFQYdALitRZXRzf3RqWumdgF79NQDBOa2V/iYSHAAAAABJRU5ErkJggg==");bottom:10px;right:55px}div.vis-network div.vis-navigation div.vis-button.vis-zoomExtends{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAABptJREFUeNqsl21QlNcVx///cx9hIipuAJHasgHlRdw0xay7yK7smg6sb2DSdtqZduLUNENmOk1tQuM4U7UzTvshSRlFZzoNCWSSSTJp+6VNkLCAeQHBoCCgqNBE0wUqL+KuwIiiZZ9+eHa3aAS3Sf8zO8/L3nt+95x7z7n3YWlpKUQEJAEgch9+Jola9xEC2ADBVgAOKqwCYAqKDgUJBIHPBWwFWQNdbyZFBwAC0GGIAHQSj3/8HHRdhzYbdDfwg4IjAsGvICgXAroYBiCEDkBBACBZoyST4gDwQqh7mQ4cEkhQD0EBIIggRMQAh2EiEvEYAGrdR3YSqIYCIEDaotVDeYnu/ryEjSOr43PHl8WmTBPA6PRQ7IWJrvhT/ubkU/7m1EvX+1KEUh7Ug+WkPEXgdUSkR+xrd0NJ4qjr8AEI9pGAI7mo78mHfnF+Y/K2K7iHUheuvJG6cOUNz/LvDwPobrpSl/Ruf2VOy9UPs4RSTSANwH4Y449EVdnt9ojHIeghCHYLgR+n/7zt4Np32tIWZU4hSpnjVk1t/caPfOO3/f++MNH5TVJcisoEoo4ksgbsXwYfdR1+kQplQuCFNS82Pp/9+158RTkTC0ce0OKutQeOp5PME0qcUBqyBmwGOC8vz4AWVOyE4CUqYO/Dh+p3pj//Bb6mHllqCyxd8ODVT69+uFKoOYTSnzFg7SJpzHFNQYWiQrUIsCN9V+uOh375zz179pSGI1FSUuK12+2+aGDt7e3muro6T/h57969lZdvDrT+ZbA6n0B1nfPVN7e0PjMjIgIIdkEAR1JR329yDvaE0+l/hQKA1Wr1bd682SsikUW7K+O3PesTNvaSAiXaLhGBvO86RFEoJ4Adac+eDxsgiZKSEm9NTY3n5MmT5mjBHR0d5vr6es+mTZu8SqnI+x+s+Ol5jRo0auX1jtepQaEAADKWWIbcy7ZGUmb79u1eu93uI+mtra31HLj5TGDs9rBJICCNn1GRCKGCUJAUuzzw6CfbTB6Px7t27VofAG/YXl6Ceyw9LmvIN3UxZUafKRACWyCELcHVP3vk4fDabDZf+2N/D9g+fsLEEFSooFGDogZNFkBRgSCsTcWm066jgRAU4et/F5u9nxRosmCLRmE+QdgSXCNzhW/s9rDJ63wVJx77V+V8YS6UNaW8BdOcqzx+3Ujt0F8Bcr1GMIMU5CzJHZ+rg6IGCYV2PimoyIK6lzIWrxkPTVGmRoqJFCyLTZmeq4MB5f3BVADnbpcQkzStUQMAk0YKBPfzxlhA95NQQe43QBotBECAFFyZHo6dz6CKCizAPFPivzUWqxm2AqIgnwkFvZNn4uczGK3Hah7wpet98UZ85R8aKScIcXYEWpMLkx8fvleHpNjlAWtTsakQa0pVKGcJQqMGUqCHBvfdjp/gTP6xwFzg85PdyaH2J4SUowKiw3889e4KBACnT582W5uKTV2uusAdUFlgzBcFQoFGDT35HwW+82mhqaenxwwA4WtYfRNnUkMZUqsJpEkn8cXU5yktYw2JjsTCMQDwer0ekt6GhgZPUVGRd3fu7qjqdU9Mj7mlpcVD0tvS0uKxWCyVANB5rS3x8s3BFEUFgTTLtuZndQHLBMSfB6pyZtfqMDQ3NzfqTcJisficTqc3BI+8bxh9L8corarM3fnDoIT+rACAU/7m7MOfHbCEwQDQ2Njo6erqinqTOHfuXNjjiI23+ystZ8c7smmkWgVJcN++fRARfLDhlacEUqVEQ1nm77xPrHjSh/+Djo3WmN/s/6OHEOgIPr2h63tVuq5Dud1ukETWoK3zorkzTiiONn/TKlNM4lj24m+Pf13o2wOVHqGA5MsAXjKPrDaqnMvlQnjTzhy0Nlw0d5oI5p3yN62amrk+ve5B5+hXgb47WGX52+V3NgoFOvQKAGUkkTqcbZy5XC7XHYf4zEFr3aXU7jih5uidPPOtvsmzixZr8VMrHjBHddLsHj+Z9Fb/n9a1+T/JDaXey0IpEzEKkHnU8Jj79++PeEwSSimQRGP+Gz8j5DVFBVKQtjBj6JGlNt/D8Y+OpMdlTphiEqcB4tqtsVjfjUtLLkx0J/dOnjWPTg+lEARIEHwaQJVQIYggACC/qxi6rn8ZHL4XETSsf0MU1HOk/CFGYgAwskUqY5eBitRxzn7/a0V1EEBwdqkN6jPI7y4xPmHmC5unbWdQRMqP2d86qANOksU6gvmArNQRNClqABnQgYuK0krI+wCOAyH3DK/vqOXhaf3PAO7mIRjDNV25AAAAAElFTkSuQmCC");bottom:50px;right:15px}');var uE=function(){function g(t,A){var e=this;Us(this,g),this.body=t,this.canvas=A,this.iconsCreated=!1,this.navigationHammers=[],this.boundFunctions={},this.touchTime=0,this.activated=!1,this.body.emitter.on("activate",(function(){e.activated=!0,e.configureKeyboardBindings()})),this.body.emitter.on("deactivate",(function(){e.activated=!1,e.configureKeyboardBindings()})),this.body.emitter.on("destroy",(function(){void 0!==e.keycharm&&e.keycharm.destroy()})),this.options={}}return Xs(g,[{key:"setOptions",value:function(g){void 0!==g&&(this.options=g,this.create())}},{key:"create",value:function(){!0===this.options.navigationButtons?!1===this.iconsCreated&&this.loadNavigationElements():!0===this.iconsCreated&&this.cleanNavigation(),this.configureKeyboardBindings()}},{key:"cleanNavigation",value:function(){if(0!=this.navigationHammers.length){for(var g=0;g<this.navigationHammers.length;g++)this.navigationHammers[g].destroy();this.navigationHammers=[]}this.navigationDOM&&this.navigationDOM.wrapper&&this.navigationDOM.wrapper.parentNode&&this.navigationDOM.wrapper.parentNode.removeChild(this.navigationDOM.wrapper),this.iconsCreated=!1}},{key:"loadNavigationElements",value:function(){var g=this;this.cleanNavigation(),this.navigationDOM={};var t=["up","down","left","right","zoomIn","zoomOut","zoomExtends"],A=["_moveUp","_moveDown","_moveLeft","_moveRight","_zoomIn","_zoomOut","_fit"];this.navigationDOM.wrapper=document.createElement("div"),this.navigationDOM.wrapper.className="vis-navigation",this.canvas.frame.appendChild(this.navigationDOM.wrapper);for(var e=0;e<t.length;e++){this.navigationDOM[t[e]]=document.createElement("div"),this.navigationDOM[t[e]].className="vis-button vis-"+t[e],this.navigationDOM.wrapper.appendChild(this.navigationDOM[t[e]]);var C,I,i=new mf(this.navigationDOM[t[e]]);if("_fit"===A[e])sE(i,Me(C=this._fit).call(C,this));else sE(i,Me(I=this.bindToRedraw).call(I,this,A[e]));this.navigationHammers.push(i)}var o=new mf(this.canvas.frame);aE(o,(function(){g._stopMovement()})),this.navigationHammers.push(o),this.iconsCreated=!0}},{key:"bindToRedraw",value:function(g){var t;void 0===this.boundFunctions[g]&&(this.boundFunctions[g]=Me(t=this[g]).call(t,this),this.body.emitter.on("initRedraw",this.boundFunctions[g]),this.body.emitter.emit("_startRendering"))}},{key:"unbindFromRedraw",value:function(g){void 0!==this.boundFunctions[g]&&(this.body.emitter.off("initRedraw",this.boundFunctions[g]),this.body.emitter.emit("_stopRendering"),delete this.boundFunctions[g])}},{key:"_fit",value:function(){(new Date).valueOf()-this.touchTime>700&&(this.body.emitter.emit("fit",{duration:700}),this.touchTime=(new Date).valueOf())}},{key:"_stopMovement",value:function(){for(var g in this.boundFunctions)Object.prototype.hasOwnProperty.call(this.boundFunctions,g)&&(this.body.emitter.off("initRedraw",this.boundFunctions[g]),this.body.emitter.emit("_stopRendering"));this.boundFunctions={}}},{key:"_moveUp",value:function(){this.body.view.translation.y+=this.options.keyboard.speed.y}},{key:"_moveDown",value:function(){this.body.view.translation.y-=this.options.keyboard.speed.y}},{key:"_moveLeft",value:function(){this.body.view.translation.x+=this.options.keyboard.speed.x}},{key:"_moveRight",value:function(){this.body.view.translation.x-=this.options.keyboard.speed.x}},{key:"_zoomIn",value:function(){var g=this.body.view.scale,t=this.body.view.scale*(1+this.options.keyboard.speed.zoom),A=this.body.view.translation,e=t/g,C=(1-e)*this.canvas.canvasViewCenter.x+A.x*e,I=(1-e)*this.canvas.canvasViewCenter.y+A.y*e;this.body.view.scale=t,this.body.view.translation={x:C,y:I},this.body.emitter.emit("zoom",{direction:"+",scale:this.body.view.scale,pointer:null})}},{key:"_zoomOut",value:function(){var g=this.body.view.scale,t=this.body.view.scale/(1+this.options.keyboard.speed.zoom),A=this.body.view.translation,e=t/g,C=(1-e)*this.canvas.canvasViewCenter.x+A.x*e,I=(1-e)*this.canvas.canvasViewCenter.y+A.y*e;this.body.view.scale=t,this.body.view.translation={x:C,y:I},this.body.emitter.emit("zoom",{direction:"-",scale:this.body.view.scale,pointer:null})}},{key:"configureKeyboardBindings",value:function(){var g,t,A,e,C,I,i,o,n,r,s,a,d,h,l,c,u,p,f,v,y,m,b,w,k=this;(void 0!==this.keycharm&&this.keycharm.destroy(),!0===this.options.keyboard.enabled)&&(!0===this.options.keyboard.bindToWindow?this.keycharm=cE({container:window,preventDefault:!0}):this.keycharm=cE({container:this.canvas.frame,preventDefault:!0}),this.keycharm.reset(),!0===this.activated&&(Me(g=this.keycharm).call(g,"up",(function(){k.bindToRedraw("_moveUp")}),"keydown"),Me(t=this.keycharm).call(t,"down",(function(){k.bindToRedraw("_moveDown")}),"keydown"),Me(A=this.keycharm).call(A,"left",(function(){k.bindToRedraw("_moveLeft")}),"keydown"),Me(e=this.keycharm).call(e,"right",(function(){k.bindToRedraw("_moveRight")}),"keydown"),Me(C=this.keycharm).call(C,"=",(function(){k.bindToRedraw("_zoomIn")}),"keydown"),Me(I=this.keycharm).call(I,"num+",(function(){k.bindToRedraw("_zoomIn")}),"keydown"),Me(i=this.keycharm).call(i,"num-",(function(){k.bindToRedraw("_zoomOut")}),"keydown"),Me(o=this.keycharm).call(o,"-",(function(){k.bindToRedraw("_zoomOut")}),"keydown"),Me(n=this.keycharm).call(n,"[",(function(){k.bindToRedraw("_zoomOut")}),"keydown"),Me(r=this.keycharm).call(r,"]",(function(){k.bindToRedraw("_zoomIn")}),"keydown"),Me(s=this.keycharm).call(s,"pageup",(function(){k.bindToRedraw("_zoomIn")}),"keydown"),Me(a=this.keycharm).call(a,"pagedown",(function(){k.bindToRedraw("_zoomOut")}),"keydown"),Me(d=this.keycharm).call(d,"up",(function(){k.unbindFromRedraw("_moveUp")}),"keyup"),Me(h=this.keycharm).call(h,"down",(function(){k.unbindFromRedraw("_moveDown")}),"keyup"),Me(l=this.keycharm).call(l,"left",(function(){k.unbindFromRedraw("_moveLeft")}),"keyup"),Me(c=this.keycharm).call(c,"right",(function(){k.unbindFromRedraw("_moveRight")}),"keyup"),Me(u=this.keycharm).call(u,"=",(function(){k.unbindFromRedraw("_zoomIn")}),"keyup"),Me(p=this.keycharm).call(p,"num+",(function(){k.unbindFromRedraw("_zoomIn")}),"keyup"),Me(f=this.keycharm).call(f,"num-",(function(){k.unbindFromRedraw("_zoomOut")}),"keyup"),Me(v=this.keycharm).call(v,"-",(function(){k.unbindFromRedraw("_zoomOut")}),"keyup"),Me(y=this.keycharm).call(y,"[",(function(){k.unbindFromRedraw("_zoomOut")}),"keyup"),Me(m=this.keycharm).call(m,"]",(function(){k.unbindFromRedraw("_zoomIn")}),"keyup"),Me(b=this.keycharm).call(b,"pageup",(function(){k.unbindFromRedraw("_zoomIn")}),"keyup"),Me(w=this.keycharm).call(w,"pagedown",(function(){k.unbindFromRedraw("_zoomOut")}),"keyup")))}}]),g}();function pE(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return fE(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return fE(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function fE(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}var vE=function(){function g(t,A,e){var C,I,i,o,n,r,s,a,d,h,l,c,u;Us(this,g),this.body=t,this.canvas=A,this.selectionHandler=e,this.navigationHandler=new uE(t,A),this.body.eventListeners.onTap=Me(C=this.onTap).call(C,this),this.body.eventListeners.onTouch=Me(I=this.onTouch).call(I,this),this.body.eventListeners.onDoubleTap=Me(i=this.onDoubleTap).call(i,this),this.body.eventListeners.onHold=Me(o=this.onHold).call(o,this),this.body.eventListeners.onDragStart=Me(n=this.onDragStart).call(n,this),this.body.eventListeners.onDrag=Me(r=this.onDrag).call(r,this),this.body.eventListeners.onDragEnd=Me(s=this.onDragEnd).call(s,this),this.body.eventListeners.onMouseWheel=Me(a=this.onMouseWheel).call(a,this),this.body.eventListeners.onPinch=Me(d=this.onPinch).call(d,this),this.body.eventListeners.onMouseMove=Me(h=this.onMouseMove).call(h,this),this.body.eventListeners.onRelease=Me(l=this.onRelease).call(l,this),this.body.eventListeners.onContext=Me(c=this.onContext).call(c,this),this.touchTime=0,this.drag={},this.pinch={},this.popup=void 0,this.popupObj=void 0,this.popupTimer=void 0,this.body.functions.getPointer=Me(u=this.getPointer).call(u,this),this.options={},this.defaultOptions={dragNodes:!0,dragView:!0,hover:!1,keyboard:{enabled:!1,speed:{x:10,y:10,zoom:.02},bindToWindow:!0,autoFocus:!0},navigationButtons:!1,tooltipDelay:300,zoomView:!0,zoomSpeed:1},ae(this.options,this.defaultOptions),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g=this;this.body.emitter.on("destroy",(function(){clearTimeout(g.popupTimer),delete g.body.functions.getPointer}))}},{key:"setOptions",value:function(g){if(void 0!==g){Yp(["hideEdgesOnDrag","hideEdgesOnZoom","hideNodesOnDrag","keyboard","multiselect","selectable","selectConnectedEdges"],this.options,g),of(this.options,g,"keyboard"),g.tooltip&&(ae(this.options.tooltip,g.tooltip),g.tooltip.color&&(this.options.tooltip.color=qp(g.tooltip.color)))}this.navigationHandler.setOptions(this.options)}},{key:"getPointer",value:function(g){return{x:g.x-(t=this.canvas.frame.canvas,t.getBoundingClientRect().left),y:g.y-Up(this.canvas.frame.canvas)};var t}},{key:"onTouch",value:function(g){(new Date).valueOf()-this.touchTime>50&&(this.drag.pointer=this.getPointer(g.center),this.drag.pinched=!1,this.pinch.scale=this.body.view.scale,this.touchTime=(new Date).valueOf())}},{key:"onTap",value:function(g){var t=this.getPointer(g.center),A=this.selectionHandler.options.multiselect&&(g.changedPointers[0].ctrlKey||g.changedPointers[0].metaKey);this.checkSelectionChanges(t,A),this.selectionHandler.commitAndEmit(t,g),this.selectionHandler.generateClickEvent("click",g,t)}},{key:"onDoubleTap",value:function(g){var t=this.getPointer(g.center);this.selectionHandler.generateClickEvent("doubleClick",g,t)}},{key:"onHold",value:function(g){var t=this.getPointer(g.center),A=this.selectionHandler.options.multiselect;this.checkSelectionChanges(t,A),this.selectionHandler.commitAndEmit(t,g),this.selectionHandler.generateClickEvent("click",g,t),this.selectionHandler.generateClickEvent("hold",g,t)}},{key:"onRelease",value:function(g){if((new Date).valueOf()-this.touchTime>10){var t=this.getPointer(g.center);this.selectionHandler.generateClickEvent("release",g,t),this.touchTime=(new Date).valueOf()}}},{key:"onContext",value:function(g){var t=this.getPointer({x:g.clientX,y:g.clientY});this.selectionHandler.generateClickEvent("oncontext",g,t)}},{key:"checkSelectionChanges",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];!0===t?this.selectionHandler.selectAdditionalOnPoint(g):this.selectionHandler.selectOnPoint(g)}},{key:"_determineDifference",value:function(g,t){var A=function(g,t){for(var A=[],e=0;e<g.length;e++){var C=g[e];-1===zl(t).call(t,C)&&A.push(C)}return A};return{nodes:A(g.nodes,t.nodes),edges:A(g.edges,t.edges)}}},{key:"onDragStart",value:function(g){if(!this.drag.dragging){void 0===this.drag.pointer&&this.onTouch(g);var t=this.selectionHandler.getNodeAt(this.drag.pointer);if(this.drag.dragging=!0,this.drag.selection=[],this.drag.translation=ae({},this.body.view.translation),this.drag.nodeId=void 0,g.srcEvent.shiftKey){this.body.selectionBox.show=!0;var A=this.getPointer(g.center);this.body.selectionBox.position.start={x:this.canvas._XconvertDOMtoCanvas(A.x),y:this.canvas._YconvertDOMtoCanvas(A.y)},this.body.selectionBox.position.end={x:this.canvas._XconvertDOMtoCanvas(A.x),y:this.canvas._YconvertDOMtoCanvas(A.y)}}if(void 0!==t&&!0===this.options.dragNodes){this.drag.nodeId=t.id,!1===t.isSelected()&&this.selectionHandler.setSelection({nodes:[t.id]}),this.selectionHandler.generateClickEvent("dragStart",g,this.drag.pointer);var e,C=pE(this.selectionHandler.getSelectedNodes());try{for(C.s();!(e=C.n()).done;){var I=e.value,i={id:I.id,node:I,x:I.x,y:I.y,xFixed:I.options.fixed.x,yFixed:I.options.fixed.y};I.options.fixed.x=!0,I.options.fixed.y=!0,this.drag.selection.push(i)}}catch(g){C.e(g)}finally{C.f()}}else this.selectionHandler.generateClickEvent("dragStart",g,this.drag.pointer,void 0,!0)}}},{key:"onDrag",value:function(g){var t=this;if(!0!==this.drag.pinched){this.body.emitter.emit("unlockNode");var A=this.getPointer(g.center),e=this.drag.selection;if(e&&e.length&&!0===this.options.dragNodes){this.selectionHandler.generateClickEvent("dragging",g,A);var C=A.x-this.drag.pointer.x,I=A.y-this.drag.pointer.y;Sd(e).call(e,(function(g){var A=g.node;!1===g.xFixed&&(A.x=t.canvas._XconvertDOMtoCanvas(t.canvas._XconvertCanvasToDOM(g.x)+C)),!1===g.yFixed&&(A.y=t.canvas._YconvertDOMtoCanvas(t.canvas._YconvertCanvasToDOM(g.y)+I))})),this.body.emitter.emit("startSimulation")}else{if(g.srcEvent.shiftKey){if(this.selectionHandler.generateClickEvent("dragging",g,A,void 0,!0),void 0===this.drag.pointer)return void this.onDragStart(g);this.body.selectionBox.position.end={x:this.canvas._XconvertDOMtoCanvas(A.x),y:this.canvas._YconvertDOMtoCanvas(A.y)},this.body.emitter.emit("_requestRedraw")}if(!0===this.options.dragView&&!g.srcEvent.shiftKey){if(this.selectionHandler.generateClickEvent("dragging",g,A,void 0,!0),void 0===this.drag.pointer)return void this.onDragStart(g);var i=A.x-this.drag.pointer.x,o=A.y-this.drag.pointer.y;this.body.view.translation={x:this.drag.translation.x+i,y:this.drag.translation.y+o},this.body.emitter.emit("_requestRedraw")}}}}},{key:"onDragEnd",value:function(g){var t=this;if(this.drag.dragging=!1,this.body.selectionBox.show){var A;this.body.selectionBox.show=!1;var e=this.body.selectionBox.position,C={minX:Math.min(e.start.x,e.end.x),minY:Math.min(e.start.y,e.end.y),maxX:Math.max(e.start.x,e.end.x),maxY:Math.max(e.start.y,e.end.y)},I=Uh(A=this.body.nodeIndices).call(A,(function(g){var A=t.body.nodes[g];return A.x>=C.minX&&A.x<=C.maxX&&A.y>=C.minY&&A.y<=C.maxY}));Sd(I).call(I,(function(g){return t.selectionHandler.selectObject(t.body.nodes[g])}));var i=this.getPointer(g.center);this.selectionHandler.commitAndEmit(i,g),this.selectionHandler.generateClickEvent("dragEnd",g,this.getPointer(g.center),void 0,!0),this.body.emitter.emit("_requestRedraw")}else{var o=this.drag.selection;o&&o.length?(Sd(o).call(o,(function(g){g.node.options.fixed.x=g.xFixed,g.node.options.fixed.y=g.yFixed})),this.selectionHandler.generateClickEvent("dragEnd",g,this.getPointer(g.center)),this.body.emitter.emit("startSimulation")):(this.selectionHandler.generateClickEvent("dragEnd",g,this.getPointer(g.center),void 0,!0),this.body.emitter.emit("_requestRedraw"))}}},{key:"onPinch",value:function(g){var t=this.getPointer(g.center);this.drag.pinched=!0,void 0===this.pinch.scale&&(this.pinch.scale=1);var A=this.pinch.scale*g.scale;this.zoom(A,t)}},{key:"zoom",value:function(g,t){if(!0===this.options.zoomView){var A=this.body.view.scale;g<1e-5&&(g=1e-5),g>10&&(g=10);var e=void 0;void 0!==this.drag&&!0===this.drag.dragging&&(e=this.canvas.DOMtoCanvas(this.drag.pointer));var C=this.body.view.translation,I=g/A,i=(1-I)*t.x+C.x*I,o=(1-I)*t.y+C.y*I;if(this.body.view.scale=g,this.body.view.translation={x:i,y:o},null!=e){var n=this.canvas.canvasToDOM(e);this.drag.pointer.x=n.x,this.drag.pointer.y=n.y}this.body.emitter.emit("_requestRedraw"),A<g?this.body.emitter.emit("zoom",{direction:"+",scale:this.body.view.scale,pointer:t}):this.body.emitter.emit("zoom",{direction:"-",scale:this.body.view.scale,pointer:t})}}},{key:"onMouseWheel",value:function(g){if(!0===this.options.zoomView){if(0!==g.deltaY){var t=this.body.view.scale;t*=1+(g.deltaY<0?1:-1)*(.1*this.options.zoomSpeed);var A=this.getPointer({x:g.clientX,y:g.clientY});this.zoom(t,A)}g.preventDefault()}}},{key:"onMouseMove",value:function(g){var t=this,A=this.getPointer({x:g.clientX,y:g.clientY}),e=!1;void 0!==this.popup&&(!1===this.popup.hidden&&this._checkHidePopup(A),!1===this.popup.hidden&&(e=!0,this.popup.setPosition(A.x+3,A.y-5),this.popup.show())),this.options.keyboard.autoFocus&&!1===this.options.keyboard.bindToWindow&&!0===this.options.keyboard.enabled&&this.canvas.frame.focus(),!1===e&&(void 0!==this.popupTimer&&(clearInterval(this.popupTimer),this.popupTimer=void 0),this.drag.dragging||(this.popupTimer=yc((function(){return t._checkShowPopup(A)}),this.options.tooltipDelay))),!0===this.options.hover&&this.selectionHandler.hoverObject(g,A)}},{key:"_checkShowPopup",value:function(g){var t=this.canvas._XconvertDOMtoCanvas(g.x),A=this.canvas._YconvertDOMtoCanvas(g.y),e={left:t,top:A,right:t,bottom:A},C=void 0===this.popupObj?void 0:this.popupObj.id,I=!1,i="node";if(void 0===this.popupObj){for(var o,n=this.body.nodeIndices,r=this.body.nodes,s=[],a=0;a<n.length;a++)!0===(o=r[n[a]]).isOverlappingWith(e)&&(I=!0,void 0!==o.getTitle()&&s.push(n[a]));s.length>0&&(this.popupObj=r[s[s.length-1]],I=!0)}if(void 0===this.popupObj&&!1===I){for(var d,h=this.body.edgeIndices,l=this.body.edges,c=[],u=0;u<h.length;u++)!0===(d=l[h[u]]).isOverlappingWith(e)&&!0===d.connected&&void 0!==d.getTitle()&&c.push(h[u]);c.length>0&&(this.popupObj=l[c[c.length-1]],i="edge")}void 0!==this.popupObj?this.popupObj.id!==C&&(void 0===this.popup&&(this.popup=new bf(this.canvas.frame)),this.popup.popupTargetType=i,this.popup.popupTargetId=this.popupObj.id,this.popup.setPosition(g.x+3,g.y-5),this.popup.setText(this.popupObj.getTitle()),this.popup.show(),this.body.emitter.emit("showPopup",this.popupObj.id)):void 0!==this.popup&&(this.popup.hide(),this.body.emitter.emit("hidePopup"))}},{key:"_checkHidePopup",value:function(g){var t=this.selectionHandler._pointerToPositionObject(g),A=!1;if("node"===this.popup.popupTargetType){if(void 0!==this.body.nodes[this.popup.popupTargetId]&&!0===(A=this.body.nodes[this.popup.popupTargetId].isOverlappingWith(t))){var e=this.selectionHandler.getNodeAt(g);A=void 0!==e&&e.id===this.popup.popupTargetId}}else void 0===this.selectionHandler.getNodeAt(g)&&void 0!==this.body.edges[this.popup.popupTargetId]&&(A=this.body.edges[this.popup.popupTargetId].isOverlappingWith(t));!1===A&&(this.popupObj=void 0,this.popup.hide(),this.body.emitter.emit("hidePopup"))}}]),g}(),yE=u,mE=hy,bE=rv.exports.getWeakData,wE=Xt,kE=_,xE=Kv,EE=_v,OE=Vg,TE=YC.set,DE=YC.getterFor,NE=Yn.find,RE=Yn.findIndex,PE=yE([].splice),ME=0,BE=function(g){return g.frozen||(g.frozen=new zE)},zE=function(){this.entries=[]},ZE=function(g,t){return NE(g.entries,(function(g){return g[0]===t}))};zE.prototype={get:function(g){var t=ZE(this,g);if(t)return t[1]},has:function(g){return!!ZE(this,g)},set:function(g,t){var A=ZE(this,g);A?A[1]=t:this.entries.push([g,t])},delete:function(g){var t=RE(this.entries,(function(t){return t[0]===g}));return~t&&PE(this.entries,t,1),!!~t}};var SE,FE={getConstructor:function(g,t,A,e){var C=g((function(g,C){xE(g,I),TE(g,{type:t,id:ME++,frozen:void 0}),null!=C&&EE(C,g[e],{that:g,AS_ENTRIES:A})})),I=C.prototype,i=DE(t),o=function(g,t,A){var e=i(g),C=bE(wE(t),!0);return!0===C?BE(e).set(t,A):C[e.id]=A,g};return mE(I,{delete:function(g){var t=i(this);if(!kE(g))return!1;var A=bE(g);return!0===A?BE(t).delete(g):A&&OE(A,t.id)&&delete A[t.id]},has:function(g){var t=i(this);if(!kE(g))return!1;var A=bE(g);return!0===A?BE(t).has(g):A&&OE(A,t.id)}}),mE(I,A?{get:function(g){var t=i(this);if(kE(g)){var A=bE(g);return!0===A?BE(t).get(g):A?A[t.id]:void 0}},set:function(g,t){return o(this,g,t)}}:{add:function(g){return o(this,g,!0)}}),C}},GE=e,LE=u,jE=hy,VE=rv.exports,YE=ay,WE=FE,QE=_,_E=uv,UE=YC.enforce,HE=mC,KE=!GE.ActiveXObject&&"ActiveXObject"in GE,XE=function(g){return function(){return g(this,arguments.length?arguments[0]:void 0)}},JE=YE("WeakMap",XE,WE);if(HE&&KE){SE=WE.getConstructor(XE,"WeakMap",!0),VE.enable();var qE=JE.prototype,$E=LE(qE.delete),gO=LE(qE.has),tO=LE(qE.get),AO=LE(qE.set);jE(qE,{delete:function(g){if(QE(g)&&!_E(g)){var t=UE(this);return t.frozen||(t.frozen=new SE),$E(this,g)||t.frozen.delete(g)}return $E(this,g)},has:function(g){if(QE(g)&&!_E(g)){var t=UE(this);return t.frozen||(t.frozen=new SE),gO(this,g)||t.frozen.has(g)}return gO(this,g)},get:function(g){if(QE(g)&&!_E(g)){var t=UE(this);return t.frozen||(t.frozen=new SE),gO(this,g)?tO(this,g):t.frozen.get(g)}return tO(this,g)},set:function(g,t){if(QE(g)&&!_E(g)){var A=UE(this);A.frozen||(A.frozen=new SE),gO(this,g)?AO(this,g,t):A.frozen.set(g,t)}else AO(this,g,t);return this}})}var eO,CO,IO,iO,oO,nO=U.WeakMap;
+/*! *****************************************************************************
+	Copyright (c) Microsoft Corporation.
+
+	Permission to use, copy, modify, and/or distribute this software for any
+	purpose with or without fee is hereby granted.
+
+	THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+	REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+	AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+	INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+	LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+	OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+	PERFORMANCE OF THIS SOFTWARE.
+	***************************************************************************** */
+function rO(g,t,A,e){if("a"===A&&!e)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof t?g!==t||!e:!t.has(g))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===A?e:"a"===A?e.call(g):e?e.value:t.get(g)}function sO(g,t,A,e,C){if("m"===e)throw new TypeError("Private method is not writable");if("a"===e&&!C)throw new TypeError("Private accessor was defined without a setter");if("function"==typeof t?g!==t||!C:!t.has(g))throw new TypeError("Cannot write private member to an object whose class did not declare it");return"a"===e?C.call(g,A):C?C.value=A:t.set(g,A),A}function aO(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return dO(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return dO(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function dO(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}function hO(g,t){var A,e=new Jm,C=aO(t);try{for(C.s();!(A=C.n()).done;){var I=A.value;g.has(I)||e.add(I)}}catch(g){C.e(g)}finally{C.f()}return e}var lO=function(){function g(){Us(this,g),eO.set(this,new Jm),CO.set(this,new Jm)}return Xs(g,[{key:"size",get:function(){return rO(this,CO,"f").size}},{key:"add",value:function(){for(var g=arguments.length,t=new Array(g),A=0;A<g;A++)t[A]=arguments[A];for(var e=0,C=t;e<C.length;e++){var I=C[e];rO(this,CO,"f").add(I)}}},{key:"delete",value:function(){for(var g=arguments.length,t=new Array(g),A=0;A<g;A++)t[A]=arguments[A];for(var e=0,C=t;e<C.length;e++){var I=C[e];rO(this,CO,"f").delete(I)}}},{key:"clear",value:function(){rO(this,CO,"f").clear()}},{key:"getSelection",value:function(){return gd(rO(this,CO,"f"))}},{key:"getChanges",value:function(){return{added:gd(hO(rO(this,eO,"f"),rO(this,CO,"f"))),deleted:gd(hO(rO(this,CO,"f"),rO(this,eO,"f"))),previous:gd(new Jm(rO(this,eO,"f"))),current:gd(new Jm(rO(this,CO,"f")))}}},{key:"commit",value:function(){var g=this.getChanges();sO(this,eO,rO(this,CO,"f"),"f"),sO(this,CO,new Jm(rO(this,eO,"f")),"f");var t,A=aO(g.added);try{for(A.s();!(t=A.n()).done;){t.value.select()}}catch(g){A.e(g)}finally{A.f()}var e,C=aO(g.deleted);try{for(C.s();!(e=C.n()).done;){e.value.unselect()}}catch(g){C.e(g)}finally{C.f()}return g}}]),g}();eO=new nO,CO=new nO;var cO=function(){function g(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){};Us(this,g),IO.set(this,new lO),iO.set(this,new lO),oO.set(this,void 0),sO(this,oO,t,"f")}return Xs(g,[{key:"sizeNodes",get:function(){return rO(this,IO,"f").size}},{key:"sizeEdges",get:function(){return rO(this,iO,"f").size}},{key:"getNodes",value:function(){return rO(this,IO,"f").getSelection()}},{key:"getEdges",value:function(){return rO(this,iO,"f").getSelection()}},{key:"addNodes",value:function(){var g;(g=rO(this,IO,"f")).add.apply(g,arguments)}},{key:"addEdges",value:function(){var g;(g=rO(this,iO,"f")).add.apply(g,arguments)}},{key:"deleteNodes",value:function(g){rO(this,IO,"f").delete(g)}},{key:"deleteEdges",value:function(g){rO(this,iO,"f").delete(g)}},{key:"clear",value:function(){rO(this,IO,"f").clear(),rO(this,iO,"f").clear()}},{key:"commit",value:function(){for(var g,t,A={nodes:rO(this,IO,"f").commit(),edges:rO(this,iO,"f").commit()},e=arguments.length,C=new Array(e),I=0;I<e;I++)C[I]=arguments[I];return(g=rO(this,oO,"f")).call.apply(g,od(t=[this,A]).call(t,C)),A}}]),g}();function uO(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return pO(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return pO(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function pO(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}IO=new nO,iO=new nO,oO=new nO;var fO=function(){function g(t,A){var e=this;Us(this,g),this.body=t,this.canvas=A,this._selectionAccumulator=new cO,this.hoverObj={nodes:{},edges:{}},this.options={},this.defaultOptions={multiselect:!1,selectable:!0,selectConnectedEdges:!0,hoverConnectedEdges:!0},ae(this.options,this.defaultOptions),this.body.emitter.on("_dataChanged",(function(){e.updateSelection()}))}return Xs(g,[{key:"setOptions",value:function(g){if(void 0!==g){Vp(["multiselect","hoverConnectedEdges","selectable","selectConnectedEdges"],this.options,g)}}},{key:"selectOnPoint",value:function(g){var t=!1;if(!0===this.options.selectable){var A=this.getNodeAt(g)||this.getEdgeAt(g);this.unselectAll(),void 0!==A&&(t=this.selectObject(A)),this.body.emitter.emit("_requestRedraw")}return t}},{key:"selectAdditionalOnPoint",value:function(g){var t=!1;if(!0===this.options.selectable){var A=this.getNodeAt(g)||this.getEdgeAt(g);void 0!==A&&(t=!0,!0===A.isSelected()?this.deselectObject(A):this.selectObject(A),this.body.emitter.emit("_requestRedraw"))}return t}},{key:"_initBaseEvent",value:function(g,t){var A={};return A.pointer={DOM:{x:t.x,y:t.y},canvas:this.canvas.DOMtoCanvas(t)},A.event=g,A}},{key:"generateClickEvent",value:function(g,t,A,e){var C=arguments.length>4&&void 0!==arguments[4]&&arguments[4],I=this._initBaseEvent(t,A);if(!0===C)I.nodes=[],I.edges=[];else{var i=this.getSelection();I.nodes=i.nodes,I.edges=i.edges}void 0!==e&&(I.previousSelection=e),"click"==g&&(I.items=this.getClickedItems(A)),void 0!==t.controlEdge&&(I.controlEdge=t.controlEdge),this.body.emitter.emit(g,I)}},{key:"selectObject",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this.options.selectConnectedEdges;if(void 0!==g){if(g instanceof Qk){var A;if(!0===t)(A=this._selectionAccumulator).addEdges.apply(A,gd(g.edges));this._selectionAccumulator.addNodes(g)}else this._selectionAccumulator.addEdges(g);return!0}return!1}},{key:"deselectObject",value:function(g){!0===g.isSelected()&&(g.selected=!1,this._removeFromSelection(g))}},{key:"_getAllNodesOverlappingWith",value:function(g){for(var t=[],A=this.body.nodes,e=0;e<this.body.nodeIndices.length;e++){var C=this.body.nodeIndices[e];A[C].isOverlappingWith(g)&&t.push(C)}return t}},{key:"_pointerToPositionObject",value:function(g){var t=this.canvas.DOMtoCanvas(g);return{left:t.x-1,top:t.y+1,right:t.x+1,bottom:t.y-1}}},{key:"getNodeAt",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],A=this._pointerToPositionObject(g),e=this._getAllNodesOverlappingWith(A);return e.length>0?!0===t?this.body.nodes[e[e.length-1]]:e[e.length-1]:void 0}},{key:"_getEdgesOverlappingWith",value:function(g,t){for(var A=this.body.edges,e=0;e<this.body.edgeIndices.length;e++){var C=this.body.edgeIndices[e];A[C].isOverlappingWith(g)&&t.push(C)}}},{key:"_getAllEdgesOverlappingWith",value:function(g){var t=[];return this._getEdgesOverlappingWith(g,t),t}},{key:"getEdgeAt",value:function(g){for(var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],A=this.canvas.DOMtoCanvas(g),e=10,C=null,I=this.body.edges,i=0;i<this.body.edgeIndices.length;i++){var o=this.body.edgeIndices[i],n=I[o];if(n.connected){var r=n.from.x,s=n.from.y,a=n.to.x,d=n.to.y,h=n.edgeType.getDistanceToEdge(r,s,a,d,A.x,A.y);h<e&&(C=o,e=h)}}return null!==C?!0===t?this.body.edges[C]:C:void 0}},{key:"_addToHover",value:function(g){g instanceof Qk?this.hoverObj.nodes[g.id]=g:this.hoverObj.edges[g.id]=g}},{key:"_removeFromSelection",value:function(g){var t;g instanceof Qk?(this._selectionAccumulator.deleteNodes(g),(t=this._selectionAccumulator).deleteEdges.apply(t,gd(g.edges))):this._selectionAccumulator.deleteEdges(g)}},{key:"unselectAll",value:function(){this._selectionAccumulator.clear()}},{key:"getSelectedNodeCount",value:function(){return this._selectionAccumulator.sizeNodes}},{key:"getSelectedEdgeCount",value:function(){return this._selectionAccumulator.sizeEdges}},{key:"_hoverConnectedEdges",value:function(g){for(var t=0;t<g.edges.length;t++){var A=g.edges[t];A.hover=!0,this._addToHover(A)}}},{key:"emitBlurEvent",value:function(g,t,A){var e=this._initBaseEvent(g,t);!0===A.hover&&(A.hover=!1,A instanceof Qk?(e.node=A.id,this.body.emitter.emit("blurNode",e)):(e.edge=A.id,this.body.emitter.emit("blurEdge",e)))}},{key:"emitHoverEvent",value:function(g,t,A){var e=this._initBaseEvent(g,t),C=!1;return!1===A.hover&&(A.hover=!0,this._addToHover(A),C=!0,A instanceof Qk?(e.node=A.id,this.body.emitter.emit("hoverNode",e)):(e.edge=A.id,this.body.emitter.emit("hoverEdge",e))),C}},{key:"hoverObject",value:function(g,t){var A=this.getNodeAt(t);void 0===A&&(A=this.getEdgeAt(t));var e=!1;for(var C in this.hoverObj.nodes)Object.prototype.hasOwnProperty.call(this.hoverObj.nodes,C)&&(void 0===A||A instanceof Qk&&A.id!=C||A instanceof Yx)&&(this.emitBlurEvent(g,t,this.hoverObj.nodes[C]),delete this.hoverObj.nodes[C],e=!0);for(var I in this.hoverObj.edges)Object.prototype.hasOwnProperty.call(this.hoverObj.edges,I)&&(!0===e?(this.hoverObj.edges[I].hover=!1,delete this.hoverObj.edges[I]):(void 0===A||A instanceof Yx&&A.id!=I||A instanceof Qk&&!A.hover)&&(this.emitBlurEvent(g,t,this.hoverObj.edges[I]),delete this.hoverObj.edges[I],e=!0));if(void 0!==A){var i=yd(this.hoverObj.edges).length,o=yd(this.hoverObj.nodes).length;(e||A instanceof Yx&&0===i&&0===o||A instanceof Qk&&0===i&&0===o)&&(e=this.emitHoverEvent(g,t,A)),A instanceof Qk&&!0===this.options.hoverConnectedEdges&&this._hoverConnectedEdges(A)}!0===e&&this.body.emitter.emit("_requestRedraw")}},{key:"commitWithoutEmitting",value:function(){this._selectionAccumulator.commit()}},{key:"commitAndEmit",value:function(g,t){var A=!1,e=this._selectionAccumulator.commit(),C={nodes:e.nodes.previous,edges:e.edges.previous};e.edges.deleted.length>0&&(this.generateClickEvent("deselectEdge",t,g,C),A=!0),e.nodes.deleted.length>0&&(this.generateClickEvent("deselectNode",t,g,C),A=!0),e.nodes.added.length>0&&(this.generateClickEvent("selectNode",t,g),A=!0),e.edges.added.length>0&&(this.generateClickEvent("selectEdge",t,g),A=!0),!0===A&&this.generateClickEvent("select",t,g)}},{key:"getSelection",value:function(){return{nodes:this.getSelectedNodeIds(),edges:this.getSelectedEdgeIds()}}},{key:"getSelectedNodes",value:function(){return this._selectionAccumulator.getNodes()}},{key:"getSelectedEdges",value:function(){return this._selectionAccumulator.getEdges()}},{key:"getSelectedNodeIds",value:function(){var g;return pd(g=this._selectionAccumulator.getNodes()).call(g,(function(g){return g.id}))}},{key:"getSelectedEdgeIds",value:function(){var g;return pd(g=this._selectionAccumulator.getEdges()).call(g,(function(g){return g.id}))}},{key:"setSelection",value:function(g){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(!g||!g.nodes&&!g.edges)throw new TypeError("Selection must be an object with nodes and/or edges properties");if((t.unselectAll||void 0===t.unselectAll)&&this.unselectAll(),g.nodes){var A,e=uO(g.nodes);try{for(e.s();!(A=e.n()).done;){var C=A.value,I=this.body.nodes[C];if(!I)throw new RangeError('Node with id "'+C+'" not found');this.selectObject(I,t.highlightEdges)}}catch(g){e.e(g)}finally{e.f()}}if(g.edges){var i,o=uO(g.edges);try{for(o.s();!(i=o.n()).done;){var n=i.value,r=this.body.edges[n];if(!r)throw new RangeError('Edge with id "'+n+'" not found');this.selectObject(r)}}catch(g){o.e(g)}finally{o.f()}}this.body.emitter.emit("_requestRedraw"),this._selectionAccumulator.commit()}},{key:"selectNodes",value:function(g){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(!g||void 0===g.length)throw"Selection must be an array with ids";this.setSelection({nodes:g},{highlightEdges:t})}},{key:"selectEdges",value:function(g){if(!g||void 0===g.length)throw"Selection must be an array with ids";this.setSelection({edges:g})}},{key:"updateSelection",value:function(){for(var g in this._selectionAccumulator.getNodes())Object.prototype.hasOwnProperty.call(this.body.nodes,g.id)||this._selectionAccumulator.deleteNodes(g);for(var t in this._selectionAccumulator.getEdges())Object.prototype.hasOwnProperty.call(this.body.edges,t.id)||this._selectionAccumulator.deleteEdges(t)}},{key:"getClickedItems",value:function(g){for(var t=this.canvas.DOMtoCanvas(g),A=[],e=this.body.nodeIndices,C=this.body.nodes,I=e.length-1;I>=0;I--){var i=C[e[I]].getItemsOnPoint(t);A.push.apply(A,i)}for(var o=this.body.edgeIndices,n=this.body.edges,r=o.length-1;r>=0;r--){var s=n[o[r]].getItemsOnPoint(t);A.push.apply(A,s)}return A}}]),g}(),vO={};!function(g){!function(g){function t(g,t){if(!(g instanceof t))throw new TypeError("Cannot call a class as a function")}g.__esModule=!0,g.sort=c;var A=32,e=7,C=256,I=[1,10,100,1e3,1e4,1e5,1e6,1e7,1e8,1e9];function i(g){return g<1e5?g<100?g<10?0:1:g<1e4?g<1e3?2:3:4:g<1e7?g<1e6?5:6:g<1e9?g<1e8?7:8:9}function o(g,t){if(g===t)return 0;if(~~g===g&&~~t===t){if(0===g||0===t)return g<t?-1:1;if(g<0||t<0){if(t>=0)return-1;if(g>=0)return 1;g=-g,t=-t}var A=i(g),e=i(t),C=0;return A<e?(g*=I[e-A-1],t/=10,C=-1):A>e&&(t*=I[A-e-1],g/=10,C=1),g===t?C:g<t?-1:1}var o=String(g),n=String(t);return o===n?0:o<n?-1:1}function n(g){for(var t=0;g>=A;)t|=1&g,g>>=1;return g+t}function r(g,t,A,e){var C=t+1;if(C===A)return 1;if(e(g[C++],g[t])<0){for(;C<A&&e(g[C],g[C-1])<0;)C++;s(g,t,C)}else for(;C<A&&e(g[C],g[C-1])>=0;)C++;return C-t}function s(g,t,A){for(A--;t<A;){var e=g[t];g[t++]=g[A],g[A--]=e}}function a(g,t,A,e,C){for(e===t&&e++;e<A;e++){for(var I=g[e],i=t,o=e;i<o;){var n=i+o>>>1;C(I,g[n])<0?o=n:i=n+1}var r=e-i;switch(r){case 3:g[i+3]=g[i+2];case 2:g[i+2]=g[i+1];case 1:g[i+1]=g[i];break;default:for(;r>0;)g[i+r]=g[i+r-1],r--}g[i]=I}}function d(g,t,A,e,C,I){var i=0,o=0,n=1;if(I(g,t[A+C])>0){for(o=e-C;n<o&&I(g,t[A+C+n])>0;)i=n,(n=1+(n<<1))<=0&&(n=o);n>o&&(n=o),i+=C,n+=C}else{for(o=C+1;n<o&&I(g,t[A+C-n])<=0;)i=n,(n=1+(n<<1))<=0&&(n=o);n>o&&(n=o);var r=i;i=C-n,n=C-r}for(i++;i<n;){var s=i+(n-i>>>1);I(g,t[A+s])>0?i=s+1:n=s}return n}function h(g,t,A,e,C,I){var i=0,o=0,n=1;if(I(g,t[A+C])<0){for(o=C+1;n<o&&I(g,t[A+C-n])<0;)i=n,(n=1+(n<<1))<=0&&(n=o);n>o&&(n=o);var r=i;i=C-n,n=C-r}else{for(o=e-C;n<o&&I(g,t[A+C+n])>=0;)i=n,(n=1+(n<<1))<=0&&(n=o);n>o&&(n=o),i+=C,n+=C}for(i++;i<n;){var s=i+(n-i>>>1);I(g,t[A+s])<0?n=s:i=s+1}return n}var l=function(){function g(A,I){t(this,g),this.array=null,this.compare=null,this.minGallop=e,this.length=0,this.tmpStorageLength=C,this.stackLength=0,this.runStart=null,this.runLength=null,this.stackSize=0,this.array=A,this.compare=I,this.length=A.length,this.length<2*C&&(this.tmpStorageLength=this.length>>>1),this.tmp=new Array(this.tmpStorageLength),this.stackLength=this.length<120?5:this.length<1542?10:this.length<119151?19:40,this.runStart=new Array(this.stackLength),this.runLength=new Array(this.stackLength)}return g.prototype.pushRun=function(g,t){this.runStart[this.stackSize]=g,this.runLength[this.stackSize]=t,this.stackSize+=1},g.prototype.mergeRuns=function(){for(;this.stackSize>1;){var g=this.stackSize-2;if(g>=1&&this.runLength[g-1]<=this.runLength[g]+this.runLength[g+1]||g>=2&&this.runLength[g-2]<=this.runLength[g]+this.runLength[g-1])this.runLength[g-1]<this.runLength[g+1]&&g--;else if(this.runLength[g]>this.runLength[g+1])break;this.mergeAt(g)}},g.prototype.forceMergeRuns=function(){for(;this.stackSize>1;){var g=this.stackSize-2;g>0&&this.runLength[g-1]<this.runLength[g+1]&&g--,this.mergeAt(g)}},g.prototype.mergeAt=function(g){var t=this.compare,A=this.array,e=this.runStart[g],C=this.runLength[g],I=this.runStart[g+1],i=this.runLength[g+1];this.runLength[g]=C+i,g===this.stackSize-3&&(this.runStart[g+1]=this.runStart[g+2],this.runLength[g+1]=this.runLength[g+2]),this.stackSize--;var o=h(A[I],A,e,C,0,t);e+=o,0!=(C-=o)&&0!==(i=d(A[e+C-1],A,I,i,i-1,t))&&(C<=i?this.mergeLow(e,C,I,i):this.mergeHigh(e,C,I,i))},g.prototype.mergeLow=function(g,t,A,C){var I=this.compare,i=this.array,o=this.tmp,n=0;for(n=0;n<t;n++)o[n]=i[g+n];var r=0,s=A,a=g;if(i[a++]=i[s++],0!=--C)if(1!==t){for(var l=this.minGallop;;){var c=0,u=0,p=!1;do{if(I(i[s],o[r])<0){if(i[a++]=i[s++],u++,c=0,0==--C){p=!0;break}}else if(i[a++]=o[r++],c++,u=0,1==--t){p=!0;break}}while((c|u)<l);if(p)break;do{if(0!==(c=h(i[s],o,r,t,0,I))){for(n=0;n<c;n++)i[a+n]=o[r+n];if(a+=c,r+=c,(t-=c)<=1){p=!0;break}}if(i[a++]=i[s++],0==--C){p=!0;break}if(0!==(u=d(o[r],i,s,C,0,I))){for(n=0;n<u;n++)i[a+n]=i[s+n];if(a+=u,s+=u,0==(C-=u)){p=!0;break}}if(i[a++]=o[r++],1==--t){p=!0;break}l--}while(c>=e||u>=e);if(p)break;l<0&&(l=0),l+=2}if(this.minGallop=l,l<1&&(this.minGallop=1),1===t){for(n=0;n<C;n++)i[a+n]=i[s+n];i[a+C]=o[r]}else{if(0===t)throw new Error("mergeLow preconditions were not respected");for(n=0;n<t;n++)i[a+n]=o[r+n]}}else{for(n=0;n<C;n++)i[a+n]=i[s+n];i[a+C]=o[r]}else for(n=0;n<t;n++)i[a+n]=o[r+n]},g.prototype.mergeHigh=function(g,t,A,C){var I=this.compare,i=this.array,o=this.tmp,n=0;for(n=0;n<C;n++)o[n]=i[A+n];var r=g+t-1,s=C-1,a=A+C-1,l=0,c=0;if(i[a--]=i[r--],0!=--t)if(1!==C){for(var u=this.minGallop;;){var p=0,f=0,v=!1;do{if(I(o[s],i[r])<0){if(i[a--]=i[r--],p++,f=0,0==--t){v=!0;break}}else if(i[a--]=o[s--],f++,p=0,1==--C){v=!0;break}}while((p|f)<u);if(v)break;do{if(0!=(p=t-h(o[s],i,g,t,t-1,I))){for(t-=p,c=1+(a-=p),l=1+(r-=p),n=p-1;n>=0;n--)i[c+n]=i[l+n];if(0===t){v=!0;break}}if(i[a--]=o[s--],1==--C){v=!0;break}if(0!=(f=C-d(i[r],o,0,C,C-1,I))){for(C-=f,c=1+(a-=f),l=1+(s-=f),n=0;n<f;n++)i[c+n]=o[l+n];if(C<=1){v=!0;break}}if(i[a--]=i[r--],0==--t){v=!0;break}u--}while(p>=e||f>=e);if(v)break;u<0&&(u=0),u+=2}if(this.minGallop=u,u<1&&(this.minGallop=1),1===C){for(c=1+(a-=t),l=1+(r-=t),n=t-1;n>=0;n--)i[c+n]=i[l+n];i[a]=o[s]}else{if(0===C)throw new Error("mergeHigh preconditions were not respected");for(l=a-(C-1),n=0;n<C;n++)i[l+n]=o[n]}}else{for(c=1+(a-=t),l=1+(r-=t),n=t-1;n>=0;n--)i[c+n]=i[l+n];i[a]=o[s]}else for(l=a-(C-1),n=0;n<C;n++)i[l+n]=o[n]},g}();function c(g,t,e,C){if(!Array.isArray(g))throw new TypeError("Can only sort arrays");t?"function"!=typeof t&&(C=e,e=t,t=o):t=o,e||(e=0),C||(C=g.length);var I=C-e;if(!(I<2)){var i=0;if(I<A)a(g,e,C,e+(i=r(g,e,C,t)),t);else{var s=new l(g,t),d=n(I);do{if((i=r(g,e,C,t))<d){var h=I;h>d&&(h=d),a(g,e,e+h,e+i,t),i=h}s.pushRun(e,i),s.mergeRuns(),I-=i,e+=i}while(0!==I);s.forceMergeRuns()}}}}(g)}(vO);var yO=vO;function mO(g){var t=function(){if("undefined"==typeof Reflect||!om)return!1;if(om.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(om(Boolean,[],(function(){}))),!0}catch(g){return!1}}();return function(){var A,e=cm(g);if(t){var C=cm(this).constructor;A=om(e,arguments,C)}else A=e.apply(this,arguments);return hm(this,A)}}var bO=function(){function g(){Us(this,g)}return Xs(g,[{key:"abstract",value:function(){throw new Error("Can't instantiate abstract class!")}},{key:"fake_use",value:function(){}},{key:"curveType",value:function(){return this.abstract()}},{key:"getPosition",value:function(g){return this.fake_use(g),this.abstract()}},{key:"setPosition",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:void 0;this.fake_use(g,t,A),this.abstract()}},{key:"getTreeSize",value:function(g){return this.fake_use(g),this.abstract()}},{key:"sort",value:function(g){this.fake_use(g),this.abstract()}},{key:"fix",value:function(g,t){this.fake_use(g,t),this.abstract()}},{key:"shift",value:function(g,t){this.fake_use(g,t),this.abstract()}}]),g}(),wO=function(g){dm(A,g);var t=mO(A);function A(g){var e;return Us(this,A),(e=t.call(this)).layout=g,e}return Xs(A,[{key:"curveType",value:function(){return"horizontal"}},{key:"getPosition",value:function(g){return g.x}},{key:"setPosition",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:void 0;void 0!==A&&this.layout.hierarchical.addToOrdering(g,A),g.x=t}},{key:"getTreeSize",value:function(g){var t=this.layout.hierarchical.getTreeSize(this.layout.body.nodes,g);return{min:t.min_x,max:t.max_x}}},{key:"sort",value:function(g){yO.sort(g,(function(g,t){return g.x-t.x}))}},{key:"fix",value:function(g,t){g.y=this.layout.options.hierarchical.levelSeparation*t,g.options.fixed.y=!0}},{key:"shift",value:function(g,t){this.layout.body.nodes[g].x+=t}}]),A}(bO),kO=function(g){dm(A,g);var t=mO(A);function A(g){var e;return Us(this,A),(e=t.call(this)).layout=g,e}return Xs(A,[{key:"curveType",value:function(){return"vertical"}},{key:"getPosition",value:function(g){return g.y}},{key:"setPosition",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]?arguments[2]:void 0;void 0!==A&&this.layout.hierarchical.addToOrdering(g,A),g.y=t}},{key:"getTreeSize",value:function(g){var t=this.layout.hierarchical.getTreeSize(this.layout.body.nodes,g);return{min:t.min_y,max:t.max_y}}},{key:"sort",value:function(g){yO.sort(g,(function(g,t){return g.y-t.y}))}},{key:"fix",value:function(g,t){g.x=this.layout.options.hierarchical.levelSeparation*t,g.options.fixed.x=!0}},{key:"shift",value:function(g,t){this.layout.body.nodes[g].y+=t}}]),A}(bO),xO=Yn.every;bA({target:"Array",proto:!0,forced:!Od("every")},{every:function(g){return xO(this,g,arguments.length>1?arguments[1]:void 0)}});var EO=Oe("Array").every,OO=$,TO=EO,DO=Array.prototype,NO=function(g){var t=g.every;return g===DO||OO(DO,g)&&t===DO.every?TO:t},RO=NO;function PO(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return MO(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return MO(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function MO(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}function BO(g,t){var A=new Jm;return Sd(g).call(g,(function(g){var t;Sd(t=g.edges).call(t,(function(g){g.connected&&A.add(g)}))})),Sd(A).call(A,(function(g){var A=g.from.id,e=g.to.id;null==t[A]&&(t[A]=0),(null==t[e]||t[A]>=t[e])&&(t[e]=t[A]+1)})),t}function zO(g,t,A,e){var C,I,i=Vl(null),o=Bm(C=gd($b(e).call(e))).call(C,(function(g,t){return g+1+t.edges.length}),0),n=A+"Id",r="to"===A?1:-1,s=PO(e);try{var a=function(){var C=Xa(I.value,2),s=C[0],a=C[1];if(!e.has(s)||!g(a))return"continue";i[s]=0;for(var d=[a],h=0,l=void 0,c=function(){var g,C;if(!e.has(s))return"continue";var I=i[l.id]+r;if(Sd(g=Uh(C=l.edges).call(C,(function(g){return g.connected&&g.to!==g.from&&g[A]!==l&&e.has(g.toId)&&e.has(g.fromId)}))).call(g,(function(g){var e=g[n],C=i[e];(null==C||t(I,C))&&(i[e]=I,d.push(g[A]))})),h>o)return{v:{v:BO(e,i)}};++h};l=d.pop();){var u=c();if("continue"!==u&&"object"===$a(u))return u.v}};for(s.s();!(I=s.n()).done;){var d=a();if("continue"!==d&&"object"===$a(d))return d.v}}catch(g){s.e(g)}finally{s.f()}return i}var ZO=function(){function g(){Us(this,g),this.childrenReference={},this.parentReference={},this.trees={},this.distributionOrdering={},this.levels={},this.distributionIndex={},this.isTree=!1,this.treeIndex=-1}return Xs(g,[{key:"addRelation",value:function(g,t){void 0===this.childrenReference[g]&&(this.childrenReference[g]=[]),this.childrenReference[g].push(t),void 0===this.parentReference[t]&&(this.parentReference[t]=[]),this.parentReference[t].push(g)}},{key:"checkIfTree",value:function(){for(var g in this.parentReference)if(this.parentReference[g].length>1)return void(this.isTree=!1);this.isTree=!0}},{key:"numTrees",value:function(){return this.treeIndex+1}},{key:"setTreeIndex",value:function(g,t){void 0!==t&&void 0===this.trees[g.id]&&(this.trees[g.id]=t,this.treeIndex=Math.max(t,this.treeIndex))}},{key:"ensureLevel",value:function(g){void 0===this.levels[g]&&(this.levels[g]=0)}},{key:"getMaxLevel",value:function(g){var t=this,A={};return function g(e){if(void 0!==A[e])return A[e];var C=t.levels[e];if(t.childrenReference[e]){var I=t.childrenReference[e];if(I.length>0)for(var i=0;i<I.length;i++)C=Math.max(C,g(I[i]))}return A[e]=C,C}(g)}},{key:"levelDownstream",value:function(g,t){void 0===this.levels[t.id]&&(void 0===this.levels[g.id]&&(this.levels[g.id]=0),this.levels[t.id]=this.levels[g.id]+1)}},{key:"setMinLevelToZero",value:function(g){var t=1e9;for(var A in g)Object.prototype.hasOwnProperty.call(g,A)&&void 0!==this.levels[A]&&(t=Math.min(this.levels[A],t));for(var e in g)Object.prototype.hasOwnProperty.call(g,e)&&void 0!==this.levels[e]&&(this.levels[e]-=t)}},{key:"getTreeSize",value:function(g,t){var A=1e9,e=-1e9,C=1e9,I=-1e9;for(var i in this.trees)if(Object.prototype.hasOwnProperty.call(this.trees,i)&&this.trees[i]===t){var o=g[i];A=Math.min(o.x,A),e=Math.max(o.x,e),C=Math.min(o.y,C),I=Math.max(o.y,I)}return{min_x:A,max_x:e,min_y:C,max_y:I}}},{key:"hasSameParent",value:function(g,t){var A=this.parentReference[g.id],e=this.parentReference[t.id];if(void 0===A||void 0===e)return!1;for(var C=0;C<A.length;C++)for(var I=0;I<e.length;I++)if(A[C]==e[I])return!0;return!1}},{key:"inSameSubNetwork",value:function(g,t){return this.trees[g.id]===this.trees[t.id]}},{key:"getLevels",value:function(){return yd(this.distributionOrdering)}},{key:"addToOrdering",value:function(g,t){void 0===this.distributionOrdering[t]&&(this.distributionOrdering[t]=[]);var A=!1,e=this.distributionOrdering[t];for(var C in e)if(e[C]===g){A=!0;break}A||(this.distributionOrdering[t].push(g),this.distributionIndex[g.id]=this.distributionOrdering[t].length-1)}}]),g}(),SO=function(){function g(t){Us(this,g),this.body=t,this._resetRNG(Math.random()+":"+xd()),this.setPhysics=!1,this.options={},this.optionsBackup={physics:{}},this.defaultOptions={randomSeed:void 0,improvedLayout:!0,clusterThreshold:150,hierarchical:{enabled:!1,levelSeparation:150,nodeSpacing:100,treeSpacing:200,blockShifting:!0,edgeMinimization:!0,parentCentralization:!0,direction:"UD",sortMethod:"hubsize"}},ae(this.options,this.defaultOptions),this.bindEventListeners()}return Xs(g,[{key:"bindEventListeners",value:function(){var g=this;this.body.emitter.on("_dataChanged",(function(){g.setupHierarchicalLayout()})),this.body.emitter.on("_dataLoaded",(function(){g.layoutNetwork()})),this.body.emitter.on("_resetHierarchicalLayout",(function(){g.setupHierarchicalLayout()})),this.body.emitter.on("_adjustEdgesForHierarchicalLayout",(function(){if(!0===g.options.hierarchical.enabled){var t=g.direction.curveType();g.body.emitter.emit("_forceDisableDynamicCurves",t,!1)}}))}},{key:"setOptions",value:function(g,t){if(void 0!==g){var A=this.options.hierarchical,e=A.enabled;if(Vp(["randomSeed","improvedLayout","clusterThreshold"],this.options,g),of(this.options,g,"hierarchical"),void 0!==g.randomSeed&&this._resetRNG(g.randomSeed),!0===A.enabled)return!0===e&&this.body.emitter.emit("refresh",!0),"RL"===A.direction||"DU"===A.direction?A.levelSeparation>0&&(A.levelSeparation*=-1):A.levelSeparation<0&&(A.levelSeparation*=-1),this.setDirectionStrategy(),this.body.emitter.emit("_resetHierarchicalLayout"),this.adaptAllOptionsForHierarchicalLayout(t);if(!0===e)return this.body.emitter.emit("refresh"),Wp(t,this.optionsBackup)}return t}},{key:"_resetRNG",value:function(g){this.initialRandomSeed=g,this._rng=Tp(this.initialRandomSeed)}},{key:"adaptAllOptionsForHierarchicalLayout",value:function(g){if(!0===this.options.hierarchical.enabled){var t=this.optionsBackup.physics;void 0===g.physics||!0===g.physics?(g.physics={enabled:void 0===t.enabled||t.enabled,solver:"hierarchicalRepulsion"},t.enabled=void 0===t.enabled||t.enabled,t.solver=t.solver||"barnesHut"):"object"===$a(g.physics)?(t.enabled=void 0===g.physics.enabled||g.physics.enabled,t.solver=g.physics.solver||"barnesHut",g.physics.solver="hierarchicalRepulsion"):!1!==g.physics&&(t.solver="barnesHut",g.physics={solver:"hierarchicalRepulsion"});var A=this.direction.curveType();if(void 0===g.edges)this.optionsBackup.edges={smooth:{enabled:!0,type:"dynamic"}},g.edges={smooth:!1};else if(void 0===g.edges.smooth)this.optionsBackup.edges={smooth:{enabled:!0,type:"dynamic"}},g.edges.smooth=!1;else if("boolean"==typeof g.edges.smooth)this.optionsBackup.edges={smooth:g.edges.smooth},g.edges.smooth={enabled:g.edges.smooth,type:A};else{var e=g.edges.smooth;void 0!==e.type&&"dynamic"!==e.type&&(A=e.type),this.optionsBackup.edges={smooth:{enabled:void 0===e.enabled||e.enabled,type:void 0===e.type?"dynamic":e.type,roundness:void 0===e.roundness?.5:e.roundness,forceDirection:void 0!==e.forceDirection&&e.forceDirection}},g.edges.smooth={enabled:void 0===e.enabled||e.enabled,type:A,roundness:void 0===e.roundness?.5:e.roundness,forceDirection:void 0!==e.forceDirection&&e.forceDirection}}this.body.emitter.emit("_forceDisableDynamicCurves",A)}return g}},{key:"positionInitially",value:function(g){if(!0!==this.options.hierarchical.enabled){this._resetRNG(this.initialRandomSeed);for(var t=g.length+50,A=0;A<g.length;A++){var e=g[A],C=2*Math.PI*this._rng();void 0===e.x&&(e.x=t*Math.cos(C)),void 0===e.y&&(e.y=t*Math.sin(C))}}}},{key:"layoutNetwork",value:function(){if(!0!==this.options.hierarchical.enabled&&!0===this.options.improvedLayout){for(var g=this.body.nodeIndices,t=0,A=0;A<g.length;A++){!0===this.body.nodes[g[A]].predefinedPosition&&(t+=1)}if(t<.5*g.length){var e=0,C=this.options.clusterThreshold,I={clusterNodeProperties:{shape:"ellipse",label:"",group:"",font:{multi:!1}},clusterEdgeProperties:{label:"",font:{multi:!1},smooth:{enabled:!1}}};if(g.length>C){for(var i=g.length;g.length>C&&e<=10;){e+=1;var o=g.length;if(e%3==0?this.body.modules.clustering.clusterBridges(I):this.body.modules.clustering.clusterOutliers(I),o==g.length&&e%3!=0)return this._declusterAll(),this.body.emitter.emit("_layoutFailed"),void console.info("This network could not be positioned by this version of the improved layout algorithm. Please disable improvedLayout for better performance.")}this.body.modules.kamadaKawai.setOptions({springLength:Math.max(150,2*i)})}e>10&&console.info("The clustering didn't succeed within the amount of interations allowed, progressing with partial result."),this.body.modules.kamadaKawai.solve(g,this.body.edgeIndices,!0),this._shiftToCenter();for(var n=0;n<g.length;n++){var r=this.body.nodes[g[n]];!1===r.predefinedPosition&&(r.x+=70*(.5-this._rng()),r.y+=70*(.5-this._rng()))}this._declusterAll(),this.body.emitter.emit("_repositionBezierNodes")}}}},{key:"_shiftToCenter",value:function(){for(var g=AE.getRangeCore(this.body.nodes,this.body.nodeIndices),t=AE.findCenter(g),A=0;A<this.body.nodeIndices.length;A++){var e=this.body.nodes[this.body.nodeIndices[A]];e.x-=t.x,e.y-=t.y}}},{key:"_declusterAll",value:function(){for(var g=!0;!0===g;){g=!1;for(var t=0;t<this.body.nodeIndices.length;t++)!0===this.body.nodes[this.body.nodeIndices[t]].isCluster&&(g=!0,this.body.modules.clustering.openCluster(this.body.nodeIndices[t],{},!1));!0===g&&this.body.emitter.emit("_dataChanged")}}},{key:"getSeed",value:function(){return this.initialRandomSeed}},{key:"setupHierarchicalLayout",value:function(){if(!0===this.options.hierarchical.enabled&&this.body.nodeIndices.length>0){var g,t,A=!1,e=!1;for(t in this.lastNodeOnLevel={},this.hierarchical=new ZO,this.body.nodes)Object.prototype.hasOwnProperty.call(this.body.nodes,t)&&(void 0!==(g=this.body.nodes[t]).options.level?(A=!0,this.hierarchical.levels[t]=g.options.level):e=!0);if(!0===e&&!0===A)throw new Error("To use the hierarchical layout, nodes require either no predefined levels or levels have to be defined for all nodes.");if(!0===e){var C=this.options.hierarchical.sortMethod;"hubsize"===C?this._determineLevelsByHubsize():"directed"===C?this._determineLevelsDirected():"custom"===C&&this._determineLevelsCustomCallback()}for(var I in this.body.nodes)Object.prototype.hasOwnProperty.call(this.body.nodes,I)&&this.hierarchical.ensureLevel(I);var i=this._getDistribution();this._generateMap(),this._placeNodesByHierarchy(i),this._condenseHierarchy(),this._shiftToCenter()}}},{key:"_condenseHierarchy",value:function(){var g=this,t=!1,A={},e=function(t,A){var e=g.hierarchical.trees;for(var C in e)Object.prototype.hasOwnProperty.call(e,C)&&e[C]===t&&g.direction.shift(C,A)},C=function(){for(var t=[],A=0;A<g.hierarchical.numTrees();A++)t.push(g.direction.getTreeSize(A));return t},I=function t(A,e){if(!e[A.id]&&(e[A.id]=!0,g.hierarchical.childrenReference[A.id])){var C=g.hierarchical.childrenReference[A.id];if(C.length>0)for(var I=0;I<C.length;I++)t(g.body.nodes[C[I]],e)}},i=function(t){var A=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1e9,e=1e9,C=1e9,I=1e9,i=-1e9;for(var o in t)if(Object.prototype.hasOwnProperty.call(t,o)){var n=g.body.nodes[o],r=g.hierarchical.levels[n.id],s=g.direction.getPosition(n),a=g._getSpaceAroundNode(n,t),d=Xa(a,2),h=d[0],l=d[1];e=Math.min(h,e),C=Math.min(l,C),r<=A&&(I=Math.min(s,I),i=Math.max(s,i))}return[I,i,e,C]},o=function(t,A){var e=g.hierarchical.getMaxLevel(t.id),C=g.hierarchical.getMaxLevel(A.id);return Math.min(e,C)},n=function(t,A,e){for(var C=g.hierarchical,I=0;I<A.length;I++){var i=A[I],o=C.distributionOrdering[i];if(o.length>1)for(var n=0;n<o.length-1;n++){var r=o[n],s=o[n+1];C.hasSameParent(r,s)&&C.inSameSubNetwork(r,s)&&t(r,s,e)}}},r=function(A,e){var C=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=g.direction.getPosition(A),r=g.direction.getPosition(e),s=Math.abs(r-n),a=g.options.hierarchical.nodeSpacing;if(s>a){var d={},h={};I(A,d),I(e,h);var l=o(A,e),c=i(d,l),u=i(h,l),p=c[1],f=u[0],v=u[2],y=Math.abs(p-f);if(y>a){var m=p-f+a;m<-v+a&&(m=-v+a),m<0&&(g._shiftBlock(e.id,m),t=!0,!0===C&&g._centerParent(e))}}},s=function(e,C){for(var o=C.id,n=C.edges,r=g.hierarchical.levels[C.id],s=g.options.hierarchical.levelSeparation*g.options.hierarchical.levelSeparation,a={},d=[],h=0;h<n.length;h++){var l=n[h];if(l.toId!=l.fromId){var c=l.toId==o?l.from:l.to;a[n[h].id]=c,g.hierarchical.levels[c.id]<r&&d.push(l)}}var u=function(t,A){for(var e=0,C=0;C<A.length;C++)if(void 0!==a[A[C].id]){var I=g.direction.getPosition(a[A[C].id])-t;e+=I/Math.sqrt(I*I+s)}return e},p=function(t,A){for(var e=0,C=0;C<A.length;C++)if(void 0!==a[A[C].id]){var I=g.direction.getPosition(a[A[C].id])-t;e-=s*Math.pow(I*I+s,-1.5)}return e},f=function(t,A){for(var e=g.direction.getPosition(C),I={},i=0;i<t;i++){var o=u(e,A),n=p(e,A);if(void 0!==I[e-=Math.max(-40,Math.min(40,Math.round(o/n)))])break;I[e]=i}return e},v=f(e,d);!function(e){var o=g.direction.getPosition(C);if(void 0===A[C.id]){var n={};I(C,n),A[C.id]=n}var r=i(A[C.id]),s=r[2],a=r[3],d=e-o,h=0;d>0?h=Math.min(d,a-g.options.hierarchical.nodeSpacing):d<0&&(h=-Math.min(-d,s-g.options.hierarchical.nodeSpacing)),0!=h&&(g._shiftBlock(C.id,h),t=!0)}(v),function(A){var e=g.direction.getPosition(C),I=Xa(g._getSpaceAroundNode(C),2),i=I[0],o=I[1],n=A-e,r=e;n>0?r=Math.min(e+(o-g.options.hierarchical.nodeSpacing),A):n<0&&(r=Math.max(e-(i-g.options.hierarchical.nodeSpacing),A)),r!==e&&(g.direction.setPosition(C,r),t=!0)}(v=f(e,n))};!0===this.options.hierarchical.blockShifting&&(function(A){var e=g.hierarchical.getLevels();e=Ud(e).call(e);for(var C=0;C<A&&(t=!1,n(r,e,!0),!0===t);C++);}(5),function(){for(var t in g.body.nodes)Object.prototype.hasOwnProperty.call(g.body.nodes,t)&&g._centerParent(g.body.nodes[t])}()),!0===this.options.hierarchical.edgeMinimization&&function(A){var e=g.hierarchical.getLevels();e=Ud(e).call(e);for(var C=0;C<A;C++){t=!1;for(var I=0;I<e.length;I++)for(var i=e[I],o=g.hierarchical.distributionOrdering[i],n=0;n<o.length;n++)s(1e3,o[n]);if(!0!==t)break}}(20),!0===this.options.hierarchical.parentCentralization&&function(){var t=g.hierarchical.getLevels();t=Ud(t).call(t);for(var A=0;A<t.length;A++)for(var e=t[A],C=g.hierarchical.distributionOrdering[e],I=0;I<C.length;I++)g._centerParent(C[I])}(),function(){for(var t=C(),A=0,I=0;I<t.length-1;I++){A+=t[I].max-t[I+1].min+g.options.hierarchical.treeSpacing,e(I+1,A)}}()}},{key:"_getSpaceAroundNode",value:function(g,t){var A=!0;void 0===t&&(A=!1);var e=this.hierarchical.levels[g.id];if(void 0!==e){var C=this.hierarchical.distributionIndex[g.id],I=this.direction.getPosition(g),i=this.hierarchical.distributionOrdering[e],o=1e9,n=1e9;if(0!==C){var r=i[C-1];if(!0===A&&void 0===t[r.id]||!1===A)o=I-this.direction.getPosition(r)}if(C!=i.length-1){var s=i[C+1];if(!0===A&&void 0===t[s.id]||!1===A){var a=this.direction.getPosition(s);n=Math.min(n,a-I)}}return[o,n]}return[0,0]}},{key:"_centerParent",value:function(g){if(this.hierarchical.parentReference[g.id])for(var t=this.hierarchical.parentReference[g.id],A=0;A<t.length;A++){var e=t[A],C=this.body.nodes[e],I=this.hierarchical.childrenReference[e];if(void 0!==I){var i=this._getCenterPosition(I),o=this.direction.getPosition(C),n=Xa(this._getSpaceAroundNode(C),2),r=n[0],s=n[1],a=o-i;(a<0&&Math.abs(a)<s-this.options.hierarchical.nodeSpacing||a>0&&Math.abs(a)<r-this.options.hierarchical.nodeSpacing)&&this.direction.setPosition(C,i)}}}},{key:"_placeNodesByHierarchy",value:function(g){for(var t in this.positionedNodes={},g)if(Object.prototype.hasOwnProperty.call(g,t)){var A,e=yd(g[t]);e=this._indexArrayToNodes(e),Sb(A=this.direction).call(A,e);for(var C=0,I=0;I<e.length;I++){var i=e[I];if(void 0===this.positionedNodes[i.id]){var o=this.options.hierarchical.nodeSpacing,n=o*C;C>0&&(n=this.direction.getPosition(e[I-1])+o),this.direction.setPosition(i,n,t),this._validatePositionAndContinue(i,t,n),C++}}}}},{key:"_placeBranchNodes",value:function(g,t){var A,e=this.hierarchical.childrenReference[g];if(void 0!==e){for(var C=[],I=0;I<e.length;I++)C.push(this.body.nodes[e[I]]);Sb(A=this.direction).call(A,C);for(var i=0;i<C.length;i++){var o=C[i],n=this.hierarchical.levels[o.id];if(!(n>t&&void 0===this.positionedNodes[o.id]))return;var r=this.options.hierarchical.nodeSpacing,s=void 0;s=0===i?this.direction.getPosition(this.body.nodes[g]):this.direction.getPosition(C[i-1])+r,this.direction.setPosition(o,s,n),this._validatePositionAndContinue(o,n,s)}var a=this._getCenterPosition(C);this.direction.setPosition(this.body.nodes[g],a,t)}}},{key:"_validatePositionAndContinue",value:function(g,t,A){if(this.hierarchical.isTree){if(void 0!==this.lastNodeOnLevel[t]){var e=this.direction.getPosition(this.body.nodes[this.lastNodeOnLevel[t]]);if(A-e<this.options.hierarchical.nodeSpacing){var C=e+this.options.hierarchical.nodeSpacing-A,I=this._findCommonParent(this.lastNodeOnLevel[t],g.id);this._shiftBlock(I.withChild,C)}}this.lastNodeOnLevel[t]=g.id,this.positionedNodes[g.id]=!0,this._placeBranchNodes(g.id,t)}}},{key:"_indexArrayToNodes",value:function(g){for(var t=[],A=0;A<g.length;A++)t.push(this.body.nodes[g[A]]);return t}},{key:"_getDistribution",value:function(){var g,t,A={};for(g in this.body.nodes)if(Object.prototype.hasOwnProperty.call(this.body.nodes,g)){t=this.body.nodes[g];var e=void 0===this.hierarchical.levels[g]?0:this.hierarchical.levels[g];this.direction.fix(t,e),void 0===A[e]&&(A[e]={}),A[e][g]=t}return A}},{key:"_getActiveEdges",value:function(g){var t=this,A=[];return Hp(g.edges,(function(g){var e;-1!==zl(e=t.body.edgeIndices).call(e,g.id)&&A.push(g)})),A}},{key:"_getHubSizes",value:function(){var g=this,t={};Hp(this.body.nodeIndices,(function(A){var e=g.body.nodes[A],C=g._getActiveEdges(e).length;t[C]=!0}));var A=[];return Hp(t,(function(g){A.push(Number(g))})),Sb(yO).call(yO,A,(function(g,t){return t-g})),A}},{key:"_determineLevelsByHubsize",value:function(){for(var g=this,t=function(t,A){g.hierarchical.levelDownstream(t,A)},A=this._getHubSizes(),e=function(e){var C=A[e];if(0===C)return"break";Hp(g.body.nodeIndices,(function(A){var e=g.body.nodes[A];C===g._getActiveEdges(e).length&&g._crawlNetwork(t,A)}))},C=0;C<A.length;++C){if("break"===e(C))break}}},{key:"_determineLevelsCustomCallback",value:function(){var g=this;this._crawlNetwork((function(t,A,e){var C=g.hierarchical.levels[t.id];void 0===C&&(C=g.hierarchical.levels[t.id]=1e5);var I=(AE.cloneOptions(t,"node"),AE.cloneOptions(A,"node"),void AE.cloneOptions(e,"edge"));g.hierarchical.levels[A.id]=C+I})),this.hierarchical.setMinLevelToZero(this.body.nodes)}},{key:"_determineLevelsDirected",value:function(){var g,t=this,A=Bm(g=this.body.nodeIndices).call(g,(function(g,A){return g.set(A,t.body.nodes[A]),g}),new Ry);"roots"===this.options.hierarchical.shakeTowards?this.hierarchical.levels=function(g){return zO((function(t){var A,e;return RO(A=Uh(e=t.edges).call(e,(function(t){return g.has(t.toId)}))).call(A,(function(g){return g.from===t}))}),(function(g,t){return t<g}),"to",g)}(A):this.hierarchical.levels=function(g){return zO((function(t){var A,e;return RO(A=Uh(e=t.edges).call(e,(function(t){return g.has(t.toId)}))).call(A,(function(g){return g.to===t}))}),(function(g,t){return t>g}),"from",g)}(A),this.hierarchical.setMinLevelToZero(this.body.nodes)}},{key:"_generateMap",value:function(){var g=this;this._crawlNetwork((function(t,A){g.hierarchical.levels[A.id]>g.hierarchical.levels[t.id]&&g.hierarchical.addRelation(t.id,A.id)})),this.hierarchical.checkIfTree()}},{key:"_crawlNetwork",value:function(){var g=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){},A=arguments.length>1?arguments[1]:void 0,e={},C=function A(C,I){if(void 0===e[C.id]){var i;g.hierarchical.setTreeIndex(C,I),e[C.id]=!0;for(var o=g._getActiveEdges(C),n=0;n<o.length;n++){var r=o[n];!0===r.connected&&(i=r.toId==C.id?r.from:r.to,C.id!=i.id&&(t(C,i,r),A(i,I)))}}};if(void 0===A)for(var I=0,i=0;i<this.body.nodeIndices.length;i++){var o=this.body.nodeIndices[i];if(void 0===e[o]){var n=this.body.nodes[o];C(n,I),I+=1}}else{var r=this.body.nodes[A];if(void 0===r)return void console.error("Node not found:",A);C(r)}}},{key:"_shiftBlock",value:function(g,t){var A=this,e={};!function g(C){if(!e[C]){e[C]=!0,A.direction.shift(C,t);var I=A.hierarchical.childrenReference[C];if(void 0!==I)for(var i=0;i<I.length;i++)g(I[i])}}(g)}},{key:"_findCommonParent",value:function(g,t){var A=this,e={};return function g(t,e){var C=A.hierarchical.parentReference[e];if(void 0!==C)for(var I=0;I<C.length;I++){var i=C[I];t[i]=!0,g(t,i)}}(e,g),function g(t,e){var C=A.hierarchical.parentReference[e];if(void 0!==C)for(var I=0;I<C.length;I++){var i=C[I];if(void 0!==t[i])return{foundParent:i,withChild:e};var o=g(t,i);if(null!==o.foundParent)return o}return{foundParent:null,withChild:e}}(e,t)}},{key:"setDirectionStrategy",value:function(){var g="UD"===this.options.hierarchical.direction||"DU"===this.options.hierarchical.direction;this.direction=g?new wO(this):new kO(this)}},{key:"_getCenterPosition",value:function(g){for(var t=1e9,A=-1e9,e=0;e<g.length;e++){var C=void 0;if(void 0!==g[e].id)C=g[e];else{var I=g[e];C=this.body.nodes[I]}var i=this.direction.getPosition(C);t=Math.min(t,i),A=Math.max(A,i)}return.5*(t+A)}}]),g}();function FO(g,t){var A=void 0!==td&&An(g)||g["@@iterator"];if(!A){if(sd(g)||(A=function(g,t){var A;if(!g)return;if("string"==typeof g)return GO(g,t);var e=nd(A=Object.prototype.toString.call(g)).call(A,8,-1);"Object"===e&&g.constructor&&(e=g.constructor.name);if("Map"===e||"Set"===e)return Fo(g);if("Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e))return GO(g,t)}(g))||t&&g&&"number"==typeof g.length){A&&(g=A);var e=0,C=function(){};return{s:C,n:function(){return e>=g.length?{done:!0}:{done:!1,value:g[e++]}},e:function(g){throw g},f:C}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var I,i=!0,o=!1;return{s:function(){A=A.call(g)},n:function(){var g=A.next();return i=g.done,g},e:function(g){o=!0,I=g},f:function(){try{i||null==A.return||A.return()}finally{if(o)throw I}}}}function GO(g,t){(null==t||t>g.length)&&(t=g.length);for(var A=0,e=new Array(t);A<t;A++)e[A]=g[A];return e}Le('div.vis-network div.vis-manipulation{background:#fff;background:-moz-linear-gradient(top,#fff 0,#fcfcfc 48%,#fafafa 50%,#fcfcfc 100%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#fff),color-stop(48%,#fcfcfc),color-stop(50%,#fafafa),color-stop(100%,#fcfcfc));background:-webkit-linear-gradient(top,#fff,#fcfcfc 48%,#fafafa 50%,#fcfcfc);background:-o-linear-gradient(top,#fff 0,#fcfcfc 48%,#fafafa 50%,#fcfcfc 100%);background:-ms-linear-gradient(top,#fff 0,#fcfcfc 48%,#fafafa 50%,#fcfcfc 100%);background:linear-gradient(180deg,#fff 0,#fcfcfc 48%,#fafafa 50%,#fcfcfc);border:0 solid #d6d9d8;border-bottom:1px;box-sizing:content-box;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#ffffff",endColorstr="#fcfcfc",GradientType=0);height:28px;left:0;padding-top:4px;position:absolute;top:0;width:100%}div.vis-network button.vis-edit-mode,div.vis-network div.vis-edit-mode{height:30px;left:0;position:absolute;top:5px}div.vis-network button.vis-close{-webkit-touch-callout:none;background-color:transparent;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAADvGaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iCiAgICAgICAgICAgIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiCiAgICAgICAgICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgICAgICAgICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3htcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhtcDpDcmVhdGVEYXRlPjIwMTQtMDItMTRUMTE6NTU6MzUrMDE6MDA8L3htcDpDcmVhdGVEYXRlPgogICAgICAgICA8eG1wOk1ldGFkYXRhRGF0ZT4yMDE0LTAyLTE0VDEyOjA1OjE3KzAxOjAwPC94bXA6TWV0YWRhdGFEYXRlPgogICAgICAgICA8eG1wOk1vZGlmeURhdGU+MjAxNC0wMi0xNFQxMjowNToxNyswMTowMDwveG1wOk1vZGlmeURhdGU+CiAgICAgICAgIDx4bXBNTTpJbnN0YW5jZUlEPnhtcC5paWQ6NjU0YmM5YmQtMWI2Yi1jYjRhLTllOWQtNWY2MzgxNDVjZjk0PC94bXBNTTpJbnN0YW5jZUlEPgogICAgICAgICA8eG1wTU06RG9jdW1lbnRJRD54bXAuZGlkOjk4MmM2MGIwLWUzZjMtMDk0MC04MjU0LTFiZTliNWE0ZTE4MzwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjk4MmM2MGIwLWUzZjMtMDk0MC04MjU0LTFiZTliNWE0ZTE4MzwveG1wTU06T3JpZ2luYWxEb2N1bWVudElEPgogICAgICAgICA8eG1wTU06SGlzdG9yeT4KICAgICAgICAgICAgPHJkZjpTZXE+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmNyZWF0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDo5ODJjNjBiMC1lM2YzLTA5NDAtODI1NC0xYmU5YjVhNGUxODM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMTRUMTE6NTU6MzUrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjIxODYxNmM2LTM1MWMtNDI0OS04YWFkLWJkZDQ2ZTczNWE0NDwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0xNFQxMTo1NTozNSswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6NjU0YmM5YmQtMWI2Yi1jYjRhLTllOWQtNWY2MzgxNDVjZjk0PC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAyLTE0VDEyOjA1OjE3KzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgPC9yZGY6U2VxPgogICAgICAgICA8L3htcE1NOkhpc3Rvcnk+CiAgICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2UvcG5nPC9kYzpmb3JtYXQ+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDAwMC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDAwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjc8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+NzwvZXhpZjpQaXhlbFlEaW1lbnNpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgIAo8P3hwYWNrZXQgZW5kPSJ3Ij8+cZUZMwAAACBjSFJNAAB6JQAAgIMAAPn/AACA6QAAdTAAAOpgAAA6mAAAF2+SX8VGAAAA2ElEQVR42gDLADT/AS0tLUQFBQUVFxcXtPHx8fPl5eUNCAgITCkpKesEHx8fGgYGBjH+/v4a+Pj4qgQEBFU6OjodMTExzwQUFBSvEBAQEfX19SD19fVqNDQ0CElJSd/9/f2vAwEBAfrn5+fkBwcHLRYWFgsXFxfz29vbo9LS0uwDDQ0NDfPz81orKysXIyMj+ODg4Avh4eEa/f391gMkJCRYPz8/KUhISOMCAgKh8fHxHRsbGx4UFBQQBDk5OeY7Ozv7CAgItPb29vMEBASaJSUlTQ0NDesDAEwpT0Ko8Ri2AAAAAElFTkSuQmCC");background-position:20px 3px;background-repeat:no-repeat;border:none;cursor:pointer;height:30px;position:absolute;right:0;top:0;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;width:30px}div.vis-network button.vis-close:hover{opacity:.6}div.vis-network div.vis-edit-mode button.vis-button,div.vis-network div.vis-manipulation button.vis-button{-webkit-touch-callout:none;background-color:transparent;background-position:0 0;background-repeat:no-repeat;border:none;-moz-border-radius:15px;border-radius:15px;box-sizing:content-box;cursor:pointer;float:left;font-family:verdana;font-size:12px;height:24px;margin-left:10px;padding:0 8px;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}div.vis-network div.vis-manipulation button.vis-button:hover{box-shadow:1px 1px 8px rgba(0,0,0,.2)}div.vis-network div.vis-manipulation button.vis-button:active{box-shadow:1px 1px 8px rgba(0,0,0,.5)}div.vis-network div.vis-manipulation button.vis-button.vis-back{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAEEOaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgICAgICAgICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgICAgICAgICAgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNZXRhZGF0YURhdGU+MjAxNC0wMi0wNFQxNTowMTowOSswMTowMDwveG1wOk1ldGFkYXRhRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTQtMDItMDRUMTU6MDE6MDkrMDE6MDA8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3BuZzwvZGM6Zm9ybWF0PgogICAgICAgICA8eG1wTU06SW5zdGFuY2VJRD54bXAuaWlkOmI2YjQwMjVkLTAxNjQtMzU0OC1hOTdlLTQ4ZmYxMWM3NTYzMzwveG1wTU06SW5zdGFuY2VJRD4KICAgICAgICAgPHhtcE1NOkRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC94bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDx4bXBNTTpIaXN0b3J5PgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+Y3JlYXRlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6RUE2MEEyNEUxOTg0RTMxMUFEQUZFRkU2RUMzMzNFMDM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDEtMjNUMTk6MTg6MDcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+c2F2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDpmOWQ3OGY4ZC1lNzY0LTc1NDgtODZiNy1iNmQ1OGMzZDg2OTc8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMDRUMTU6MDE6MDkrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpjaGFuZ2VkPi88L3N0RXZ0OmNoYW5nZWQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5jb252ZXJ0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+ZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZzwvc3RFdnQ6cGFyYW1ldGVycz4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmRlcml2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+Y29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmc8L3N0RXZ0OnBhcmFtZXRlcnM+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOmI2YjQwMjVkLTAxNjQtMzU0OC1hOTdlLTQ4ZmYxMWM3NTYzMzwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0wNFQxNTowMTowOSswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC94bXBNTTpIaXN0b3J5PgogICAgICAgICA8eG1wTU06RGVyaXZlZEZyb20gcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8c3RSZWY6aW5zdGFuY2VJRD54bXAuaWlkOmY5ZDc4ZjhkLWU3NjQtNzU0OC04NmI3LWI2ZDU4YzNkODY5Nzwvc3RSZWY6aW5zdGFuY2VJRD4KICAgICAgICAgICAgPHN0UmVmOmRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwvc3RSZWY6ZG9jdW1lbnRJRD4KICAgICAgICAgICAgPHN0UmVmOm9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdFJlZjpvcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDwveG1wTU06RGVyaXZlZEZyb20+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDA5MC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDkwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz4jq1U/AAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAVTSURBVHjanFVfTFNnFP+d77ve8qeVFbBrpcVgRrCRFikFByLxwSAaE32oRCHD6JMxxhhn8G2RxxH3MsOTbyYsmCAxPMmMMYtkIUYmK60OO0qAK23BFlNob0uh3x7WS5jLZPpLbm6+k/P9zrm5v9855PF4UFhYCABgjIExBgAgIqRSqRIi6gDQRkQ1RGTB3wgR0e8AHgH4Sa/XR/EBiAiJRAJ04cIF5Ofng4g2n0gkUkxENwF0c843LzHGQEQQQkCLExEA9ALotVgsUQAQQmgNQhJCbF5kjCEUCl0moj4t5na7fTU1NUpVVVXUYrEkASAcDhe8efOmxOfzWScmJqoBdBNR99LS0hWz2dynNSSEAF28eBGFhYVgjCEcDn9HRD1EhIMHD3o9Hs9kWVlZAh9BKBQqGB4edr58+dKZ+6JbJpOpBwBWV1fB6+rqIMsyIpHIFcZYL2MMra2tY5cuXRrfuXNnBtvAYDBk3G63oqpqZm5uzgrgSDKZjBoMhueZTAbc5XIhFouVEtFTxhiOHTs2dv78eS8+Efv374+oqpqZnZ21cs5PJJPJPlmWkyynnBuMMTQ0NHi7uro+mVyDx+Pxulwu71ZOlkqlSonoJhGhvb39s8k1nDx50ss5hyRJN9PpdKlERB2aWjSVaEilUvzBgwcORVEs5eXloXPnzk1sV8BkMiUdDofP7/dXZ7PZDilnIhw4cGBeS1pbW2P37t1zBwKBikQiUUREWFhYsHHO0d7evm0Ru90+/+rVq2rO+XGJiJxEhMrKyhgAjI6OWoeHh5tWVla+4JzDZrO9bW5unhwcHGzz+/32np4e+xaDbfoHAMxmc6ijo2O0oqIiJkkSNjY2HBIRmRljMJvNyWfPnln7+/tPMMZQXl6+0NbW9qK2tjYcj8floaEhqKpq+HCkbD3PzMwYBgYG0NXV9UuusFna2kEgELAQEQ4dOvSis7PzN41Ar9dnrl27NqCNkv/C3bt3zy4tLVmICJxzEBFJRBQmorLFxcWCqqqq0Pj4eO3Y2JhbUZTdra2tL2pra8OJRGLHnTt3zkqS9K+huHU4EhHMZnMoGo0W5OIh7nK5jjLGKq1W69vDhw8rRqMxMjc3t2t5eXnX5ORklc/nM+fl5SWnpqa+0uv1K/n5+Ws6nW5NluXNd15e3ppOp1uz2WyzZ86cGQ0Gg6ZAIFCZzWZ/lYjokRDiuN/vt7W0tMw3NTUpbrd78P79++5gMFgRiUTKHj58WMYYQ3V19etTp05tq6Lp6Wkb5xxCiEfc7XZPM8a6FxcXTfX19a/1en2Gcy5qamreNjY2/qGq6joRZe12+9Tp06e3JY/FYgWPHz8+mhvr3/CWlpbk+vp6PmOseWVlBS6XS9GSJUkSdrs93NDQ8Oe+ffvC/8fJIyMjddFo9Esi6pVleVjT2m0A8Hq9zqGhIefnjoknT544A4GAM/eDbxMReFNTE0pKSpKqqsaI6Pj8/LxVVdWM3W6PfCr5xMTE1zllXS0uLn6aSqXAGxsbodPpoNfrn6uqCs75EUVRrJFIZMfevXsXdTrdxseIE4mEPDIyUu/3++tynd8yGo29RIR0Og26fv06ioqKwBgD5xzv3r27zBjrIyJIkgSHwzFZWVmp7NmzJ1ZaWpoAgGg0WqgoSvHMzIw1GAw6tvjhitFo7NPW5fv370Hd3d0oKCgA53zTQMvLy+VCiKuSJH0rSdLmztZytIWv5RPRD0T0Y3Fx8dzWfby6ugopHo//w4mcc8iyPMc5v5FOp7/PZrOdQohWInIC2C2EgBBigYi8Qoifs9lsv06nWyIiaFxagXg8jr8GAGxuIe7LBeWhAAAAAElFTkSuQmCC")}div.vis-network div.vis-manipulation div.vis-none:hover{box-shadow:1px 1px 8px transparent;cursor:default}div.vis-network div.vis-manipulation div.vis-none:active{box-shadow:1px 1px 8px transparent}div.vis-network div.vis-manipulation div.vis-none{line-height:23px;padding:0}div.vis-network div.vis-manipulation div.notification{font-weight:700;margin:2px}div.vis-network div.vis-manipulation button.vis-button.vis-add{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAEEOaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgICAgICAgICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgICAgICAgICAgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNZXRhZGF0YURhdGU+MjAxNC0wMi0wNFQxNDo0MDoyOSswMTowMDwveG1wOk1ldGFkYXRhRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTQtMDItMDRUMTQ6NDA6MjkrMDE6MDA8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3BuZzwvZGM6Zm9ybWF0PgogICAgICAgICA8eG1wTU06SW5zdGFuY2VJRD54bXAuaWlkOjVkNWIwNmQwLTVmMjAtOGE0NC1hMzIwLWZmMTEzMzQwNDc0YjwveG1wTU06SW5zdGFuY2VJRD4KICAgICAgICAgPHhtcE1NOkRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC94bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDx4bXBNTTpIaXN0b3J5PgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+Y3JlYXRlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6RUE2MEEyNEUxOTg0RTMxMUFEQUZFRkU2RUMzMzNFMDM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDEtMjNUMTk6MTg6MDcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+c2F2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDo2OWVmYWE1NS01ZTI5LTIzNGUtYTUzMy0xNDkxYjM1NDNmYmE8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMDRUMTQ6NDA6MjkrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpjaGFuZ2VkPi88L3N0RXZ0OmNoYW5nZWQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5jb252ZXJ0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+ZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZzwvc3RFdnQ6cGFyYW1ldGVycz4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmRlcml2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+Y29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmc8L3N0RXZ0OnBhcmFtZXRlcnM+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjVkNWIwNmQwLTVmMjAtOGE0NC1hMzIwLWZmMTEzMzQwNDc0Yjwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0wNFQxNDo0MDoyOSswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC94bXBNTTpIaXN0b3J5PgogICAgICAgICA8eG1wTU06RGVyaXZlZEZyb20gcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8c3RSZWY6aW5zdGFuY2VJRD54bXAuaWlkOjY5ZWZhYTU1LTVlMjktMjM0ZS1hNTMzLTE0OTFiMzU0M2ZiYTwvc3RSZWY6aW5zdGFuY2VJRD4KICAgICAgICAgICAgPHN0UmVmOmRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwvc3RSZWY6ZG9jdW1lbnRJRD4KICAgICAgICAgICAgPHN0UmVmOm9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdFJlZjpvcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDwveG1wTU06RGVyaXZlZEZyb20+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDA5MC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDkwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz5WKqp9AAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAYXSURBVHjafFZtUFTXGX7e9z27sveuMCwYV8ElrA7YSFYHtJUPkaaI0aRqG8wP00zUzljDINNSA/2ROtpO24SxnahlxjYd7SSjmUkymcxYlDhQPzHGisEVp8HwYWCVVVgEsrsuLnL74+5uqTF9Z+7cO/d8PO95zvO851BlZSV0XQcAMDOYGQBARDhX3JRmMDYZwLPMWAzGHACYIgwS46oBNBNwtOL8CwE8EkSEUCgE2rJlC2w2G4go8Zwo/bMDgnoG6gxLfAAAYvPDMCCszKTAMIAGAhrWnf15AAAMwwARIRKJgDZv3gy73Q4iAjPjxIr9VVOMRhbAYKB8zvrO0llrfEsdKwLZek6YAPSFvtSu3GtLawu0ZJ6625SHGBQB1T88t6MxvopgMAjaunUrdF0HM+P4yv27DMYeJmB1RqW3Jnf3tQX2p0L4P9EXuqEd7PmDp+XuMU9sRbvXnnt1TxxACgoKYLVacbzsQDUJGkSATe6qi28uPtzusM6Kxie6NHLGUX3lxVUNX9StPHnn4wy3njuUYcu6n2pNi66avcEXnByP/nv8aiaIyrqz2gO5A9+9FI1GIfn5+WhZdTAdjFMkwMvZOy7uWnTAOz3L4Yk71m3t69fdfTDoUGTBeHTUfiHQ6lo7Z2OXJvpDAChKe+aOCdKRKWxZ2+1qb3yyd3GYmRkQ7GQBVs99wfv6on3eR2k4PdTkDEbH7IuS8/svld/561PJS/pDk1/bzwx94pze7xc5v/H+YPY6r5BAkdrJzODTK46lE6PeYEJt7u+8j+OZwCBiEAgAoNgKJoEQf6PvNvdrXgtZoNhSf7q0KZ3B2AQmVMze0Jmt54S/DcDCVig2NcvEUGxJAE4Pl+YOr0iv6BRSIPAmBeBZAmHlE2sH4p1uhrq1s0MnnEQMBsf8wRASAICQQCCITN1X7/sOuc0kgOVp3/fPs2WHv+coG7gQOJUnLGsUCTxEjPzUohEA+NfIWUdtx0+efzA1kSSkIGyBAQNCKgHAEBAJ3u79U7kiAcWoem/gb5Fd33nrH3kp+SMWtuAB+GllMJxMjCx9QRgA3uiqL5kwHiTlpxb3smlfMDGYGPP1hcMAkJvs8ScpfdJspdj+MK6Pf+5+u29vyb4lR4+BGEziVESAkEpw6Av1OhUpHCz4qOXbzFWz4Ncdj/v/o08Lt92ODDgZDCEFJYoUGH4mzugP92puPTf0pD3H7wvfdFZdqSxnMtWjoGAAmG9fOLxjwesdjT2/XzIQ7ks3sycYMSEwGHNtWf5bkX5NkYCJBxUBXiGV0XHvosOt54Zey33j/K+8P33++vjnbiGJbbLE+J9SANAb6nJ2B79wcUwETAwQQ7fMjPzMvfP8ja87HUIKMOiaAqMZhrGmLdAy78eZrwwsTS0eObTs+IdtgVanxBUExqGbb5VzrIISGIoUXsmqbgEhJldCQWqRf27SvPAn/o8XmgLhZsUkR4ll37mhk3n94Z4OlzY/7NLcYZfm7o1z2zT4vsvUNSXqprBCkmiTFbPX90/fh8GIT2sf+zTPdDMf4dVnNg4z+E0ixsGeBs9jd5ViSgLHjCb/peaR+MD3d4/ZJg2llyuG2Vwy7QWAs8PNnn1f7vkGSGxAzE6mk+kxkx/p/4unffSCR0hAoL1EBCYiPNdWNcwkNQTCR7feWX6g+7f/A7I8rcw/U6UEe0Ndrhc/W7mtL9ztmqlSgstSS/zTJ28dalpOpkRryrwbhwBACgsLMWPGDOT4ll3qyeqAkJTdCF7P/CrUY/GkLL1rE+2hTbSH8+0Lb/WEuhzhyaA905blf9Vd/895WnZwLHrPevir/cvOB1oLYpTtLrm6oYGIMDExAaqtrUVKSgqYGSKCk0WHq5ikkWEWtNL0imv5qUW+RclLRjJsrhBAuH1/QL8R7HR4xy5nescuP23E6hOA6mLv+sb4uTw6Ogqqq6uDpmkQkcStorX4XRcM1FjZ+kvFFjCJKU1WpkNJJUqIMtX1RyLeX3JtQ0JRhmGYZ/L27duRnJycuFGISOJ9pqh5lrB6iYgqGOxRrOaa54DcZmKvkJxk8JHC9rKh+KVhOsD4+Dj+MwADIf8n5m4xGwAAAABJRU5ErkJggg==")}div.vis-network div.vis-edit-mode button.vis-button.vis-edit,div.vis-network div.vis-manipulation button.vis-button.vis-edit{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAEEOaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgICAgICAgICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgICAgICAgICAgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNZXRhZGF0YURhdGU+MjAxNC0wMi0wNVQxNDoxMjoyNSswMTowMDwveG1wOk1ldGFkYXRhRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTQtMDItMDVUMTQ6MTI6MjUrMDE6MDA8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3BuZzwvZGM6Zm9ybWF0PgogICAgICAgICA8eG1wTU06SW5zdGFuY2VJRD54bXAuaWlkOjY5OTM3ZGZjLTJjNzQtYTU0YS05OTIzLTQyMmZhNDNkMjljNDwveG1wTU06SW5zdGFuY2VJRD4KICAgICAgICAgPHhtcE1NOkRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC94bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDx4bXBNTTpIaXN0b3J5PgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+Y3JlYXRlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6RUE2MEEyNEUxOTg0RTMxMUFEQUZFRkU2RUMzMzNFMDM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDEtMjNUMTk6MTg6MDcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+c2F2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDozOWNhNzE5ZC03YzNlLTUyNGEtYmY1NS03NGVmMmM1MzE0YTc8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMDVUMTQ6MTI6MjUrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpjaGFuZ2VkPi88L3N0RXZ0OmNoYW5nZWQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5jb252ZXJ0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+ZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZzwvc3RFdnQ6cGFyYW1ldGVycz4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmRlcml2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+Y29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmc8L3N0RXZ0OnBhcmFtZXRlcnM+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjY5OTM3ZGZjLTJjNzQtYTU0YS05OTIzLTQyMmZhNDNkMjljNDwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0wNVQxNDoxMjoyNSswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC94bXBNTTpIaXN0b3J5PgogICAgICAgICA8eG1wTU06RGVyaXZlZEZyb20gcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8c3RSZWY6aW5zdGFuY2VJRD54bXAuaWlkOjM5Y2E3MTlkLTdjM2UtNTI0YS1iZjU1LTc0ZWYyYzUzMTRhNzwvc3RSZWY6aW5zdGFuY2VJRD4KICAgICAgICAgICAgPHN0UmVmOmRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwvc3RSZWY6ZG9jdW1lbnRJRD4KICAgICAgICAgICAgPHN0UmVmOm9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdFJlZjpvcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDwveG1wTU06RGVyaXZlZEZyb20+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDA5MC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDkwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz4ykninAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAYpSURBVHjafFZtTFvnFX7Oea+NudiY2Hwam4CBlgQwXdKREDKUoYg0jbRJ29RJ2VZ1mjRFUxSpA3VTfkzJfkQbS7spU6rtx5Z2UtppScjaHxvLuiatWi2jLEoMIUDCh23g2gbj7+tPuPvhOurawPl1dc99n+c55z33fV46ceIEZFkGADAziAgAQERoe/9ZK4GPM/AcgbsIXAcABCgMvkfAqAa89eDoJyF8LogIqqqChoaGYDAYHr8kItS8uc8iIH6iAa9IkAo5EAQX8pqmgUVBCBggYFgDhv0/GAsBgKZpICJkMhnQ4OAgZFkGEYGZUXmp+0cS+CKBwWA0DVRPOg5Zl2q6zaHyJlnVAMQXVTkwHrUqH0Xsvn+tdQAAMQDgpPLS2MViFY8rkGUZzIzaS/t/xqCzGggtz9e697zsnKhoLUtim4jOq/LE6x7X0nsh16dEZ5a/O3a2SCAOHjwInU6Hujd6ThJ4mCDQ+b2G232v7v6vwarPbQn8MGlMr+X0kpE3Wr5Zt5hL5HPhqYSdQIfKJ+yhxDPKWC6Xg+jt7UXD5b5KBt1kCHS85Ljd8/On3NupfnhFaZj4rWff1B98B1R/hnUmKd36bdtCNl4g0en4edNE/cXwLq8qMTMIPAQwmo/WuHvObA8+9c58k/dKtD0TyZWXN5YGA7ej7epKxspM//7SoNOdWc/Jyq2wiwhDzPxT8cP0jys3VMM7OmL0/77zn4Ydui3b8uiK0jD7RrA77c9Wd57cefPpF+2T6bWsFPWkaiPTCWvTsZpHFU+XrS+8G3AR08F6X+1FJvBxQQzHQOWk2SmrW4FPX/U2LVwPuDZj+fJKl2khPpeyAqA9rzR/YqwuiWXX8taN/CabGkrVuq9YJlkQQDjOAJ5jAhz9Vt9W4N5/rNp8I+vtMV/aZm4zLnUNNt0urdYnF68HWoJj4Wo1mLGUNRr8LEgDgNqeCh8xQIKOsgC7iAjVe83rT9zQa8uNM28u70kspessu8q8zq/V3NcZpVzb9+0zmVhOvvvrhaMVzrJg0zeq7xMVCCwdpnWSGBqjUyJwLTFgbvxie3w31uoWR1Y74r60rdxZqrR8q85t2W2MGCp12bm/KC3hyaSTiMhxuGrKcahqpbjOaDOoEhOEoFqJQCCJvqA85I6bfTdDjQlf2lbxVNlS6wt19yy7jRHZZlDnrinNj/6sHMhnNw2Ogco7O79e5fm/xQywRBBCEAuwn4gQ96bkYj4Vyuq9N1Z3Bj4Od5bs0MXt/dZZ21ctiqFan174q985P+Lfp+U1g7XDON/1ctP458WlVjLyJhOISZE0wM0S1QfuRC3lTjkJAKKEtNC9eIOhSh9xHLZOJRZTFuXDsEoStLkR/768ummsaJG9Pb9oe+9J+xaeSVokiQDSJphAo5uaBuWjiKP4QTqS1cUWU7ayesN66wu22frD1vmVW6GW6T8u9eVjGyZzs+w78Nqu0a2mbvVu1KEJQAgeZRL0liQYyx+GOmKeQpu0rMYsAJPNEFGD2dLodLIy6c9Ys7G8yeSUl3tf2/X3rcBVJSOv34l3sCBogi7z1LH/rBHjl4IJ93/ncQFAnjeImJD0Z8zuCwu9q3djDXqTlAKID5xv+9t2R8n8VcUFBljQ8Gyfe40BYBM4DwDLt8Kue79ZcFkbzfEdbUbv+oN4c9KTtsfm1MbYQqqh+2zrVZYKs/7Ef+byimt1POYiJhDhPBFBIiIEXhxfs7/dfYoIF+auBfYTE/pebx/V8hqBP2ODvD34yvuh/WCAmU75Bx6sIgaI/v5+6PV6JLqUsYr7dpDAoehs0h73pHTWrvKgThYbRSt9UmSjef3MpaUvBz4O72UmADgTOPJguGiZor+/HyUlJWBmJFz+D8xTtlUiOpbwpmrmrweeSXrT+g11k4SBN3RGKUcAVCVdFhyP1nreDbY//NPyEXUlU/Pp4XYycGT6V0Ux2WwWdO7cOZSWlkII8diX7SPPNgDaKdbxoNAxwATBAEkEEgSWCEQAqPAMwqvMdCEwMO0tVqZpWsGTT58+DaPR+PhGIYQAAAgh0P7B3ioW/B0iGiCGiwXbCuOHFSJys6AbYFye2T+xWhT3WYJEIoH/DQBMw3kes8OJPgAAAABJRU5ErkJggg==")}div.vis-network div.vis-edit-mode button.vis-button.vis-edit.vis-edit-mode{background-color:#fcfcfc;border:1px solid #ccc}div.vis-network div.vis-manipulation button.vis-button.vis-connect{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAEEOaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgICAgICAgICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgICAgICAgICAgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNZXRhZGF0YURhdGU+MjAxNC0wMi0wNFQxNDozODo1NyswMTowMDwveG1wOk1ldGFkYXRhRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTQtMDItMDRUMTQ6Mzg6NTcrMDE6MDA8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3BuZzwvZGM6Zm9ybWF0PgogICAgICAgICA8eG1wTU06SW5zdGFuY2VJRD54bXAuaWlkOjlmYjUwMDU0LWE3ODEtMWQ0OC05ZTllLTU2ZWQ5YzhlYjdjNjwveG1wTU06SW5zdGFuY2VJRD4KICAgICAgICAgPHhtcE1NOkRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC94bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDx4bXBNTTpIaXN0b3J5PgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+Y3JlYXRlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6RUE2MEEyNEUxOTg0RTMxMUFEQUZFRkU2RUMzMzNFMDM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDEtMjNUMTk6MTg6MDcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+c2F2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDo3ZWRhMjI0MC0yYTQxLTNlNDQtYWM2My1iNzNiYTE5OWI3Y2E8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMDRUMTQ6Mzg6NTcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpjaGFuZ2VkPi88L3N0RXZ0OmNoYW5nZWQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5jb252ZXJ0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+ZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZzwvc3RFdnQ6cGFyYW1ldGVycz4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmRlcml2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+Y29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmc8L3N0RXZ0OnBhcmFtZXRlcnM+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjlmYjUwMDU0LWE3ODEtMWQ0OC05ZTllLTU2ZWQ5YzhlYjdjNjwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0wNFQxNDozODo1NyswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC94bXBNTTpIaXN0b3J5PgogICAgICAgICA8eG1wTU06RGVyaXZlZEZyb20gcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8c3RSZWY6aW5zdGFuY2VJRD54bXAuaWlkOjdlZGEyMjQwLTJhNDEtM2U0NC1hYzYzLWI3M2JhMTk5YjdjYTwvc3RSZWY6aW5zdGFuY2VJRD4KICAgICAgICAgICAgPHN0UmVmOmRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwvc3RSZWY6ZG9jdW1lbnRJRD4KICAgICAgICAgICAgPHN0UmVmOm9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdFJlZjpvcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDwveG1wTU06RGVyaXZlZEZyb20+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDA5MC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDkwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz4ubxs+AAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAUtSURBVHjajJZ/bNT1Gcdfz/P53PV6B4W7VltLqdAaplIOiMOoyxxJCSs/Gv/yB4gzJroAosmmDklwkYWR0bQsdmkykoojTpcsWYLxD/lRZdMQkTHRtkLZRqG0tIVe7662vTu43n32x/VKZ/jh89cn38/zvN7P5/l88zwf2blzJz6fDwARQUSm1n8s31CM0/VAnbNmsUPuAsDpgEO+Bg4C7//iyv5hvmMiQiqVQpqamvB6vVNwEeG1JZtCBrYi/MrkAwDNgjhwAlbzICBLA0rDb0+/839C6XQaaWxspLCw8Dp86cbNmqVFJQddE6KzdjZ9D89g+B6fSyCOcyn1nxil+O9xKg5HqWFSHGXLjrP7W/ICqVQK2bNnDz6fDxFh65KNvxbHDhF4rJj2bXPo+IGfcW5h5xL4f99P+FCEMIAob75x9t0dAMlkElNXV4e1lteXbNqiQoMaeOFOjrdU868SD2luYyEP6dUh+sYmSHeOU6GO5Z8VLx5+NNZxIpPJ5AS2L3upROCoCvz8Lo7vnkf77cAHhpiz/zIL9vWz8L8p/NvupmM0Q7pjnAoLqz8tDrc8MnQqYVUVhVdF4LEg7b+rvDn8wDDlH0WoPpukLJImSBaMwjcJqmwWts2jPZLG/8kwYVFeVdXXZcFf4yVDc2cNKfBFmD9X+0ncCP58F48eG+Feo2CAUkvs4dl0V/uJvdXLiiV+ut++n7YLSfxPfMMG54ChzB3WIesVWB2i82bw1AR6fJR7C4VsfYiv6u/k3A9nEgP4zXke8DiYHyAOMK+QxPIgnZ9GqSHr1itQJ8DK2fTerDQ+S/bHRXQJaHSCwNIZ2Xh+7+S3VAmwNMBA/tuPZtErgKquUmdMWIFlRURvdamRNEXGwIWrlP47pTMzLiunxghGMwTLvcTWlHAp77s4QNSrYMQtss6ZMgWqCm5cHoDHO1nbk6K8zEN8+3zatv2Hn1b59EqJZdxmYUERg9P9KwpIiAOTdWUWBXuLzB/vZG3P1Un4PNp2d1MbmyD45TWCxuCsQm0x56bHGHFYEZwxok7toAA9Sfw3hCcoL/NOwi9QO5wmWO1j4JEgZxTkodmcWRGkf3pcX0r8xoAaBixKu4U5/xwndM+0tpAvS6mP+PZK2nb1UBvPEKwKMLDvPj4ESGc55lGy303sdJKQdZB2rkMdctAB/4gzN+/Q2ENNd4LyUi/xN+bTtquX2thk5nk4wI3gAF+OMNcA1nFQDfK+BY5GqbkwWabTY5QZhXWlnNx1ntrY1Rz87fuvw29m/Sn8J+PUGAFj5T19baA1IspuBZp7cx1x4SwG1cEf+lgRSROs8jGwb+Ht4QB/GSSsAhYano39LWIBxNEIbP14hPDuiyS2VtJuHXQlKKvxM/jiXDq/D/xPlwifGMkJZB2NIoKpr69nxeiZxLHicFSFVWfGqBidIP3LSjrWltD94CyufF/4kQgPuVz2Lz93+dDRa9eu5QQ8Hg8/iXee+Dy4CKMs7xqn4nwKz9IirhQqmVuB42m8ey+x7LMoD6iAON782eChhqmRuXfvXgKBAKqKqtI0/8nNKrQI4BVYXkzHgzPpC88gWuHL/caXrhLoGiN0apSKr0ZZRBZM7q2w5ZnLR1oAnHOMjY0hra2tFBQUYIyZmstvVT1Z6eDlAuEVq7merxmwueNPDXy9PvybjKP5mctHLk4/XTKZRJqbm/H7/VNw1VyEMYbW4FN3WNWnnchKoy5sHeVGBRX6VWi3ymFx7r11Ix8MTX/y5C2RSPC/AQB61erowbpqSwAAAABJRU5ErkJggg==")}div.vis-network div.vis-manipulation button.vis-button.vis-delete{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAEEOaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjUtYzAyMSA3OS4xNTQ5MTEsIDIwMTMvMTAvMjktMTE6NDc6MTYgICAgICAgICI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICAgICAgICAgICB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIKICAgICAgICAgICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgICAgICAgICAgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiCiAgICAgICAgICAgIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPHhtcDpDcmVhdG9yVG9vbD5BZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpPC94bXA6Q3JlYXRvclRvb2w+CiAgICAgICAgIDx4bXA6Q3JlYXRlRGF0ZT4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC94bXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhtcDpNZXRhZGF0YURhdGU+MjAxNC0wMi0wNFQxNDo0MTowNCswMTowMDwveG1wOk1ldGFkYXRhRGF0ZT4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMTQtMDItMDRUMTQ6NDE6MDQrMDE6MDA8L3htcDpNb2RpZnlEYXRlPgogICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3BuZzwvZGM6Zm9ybWF0PgogICAgICAgICA8eG1wTU06SW5zdGFuY2VJRD54bXAuaWlkOjc3NDkzYmUxLTEyZGItOTg0NC1iNDYyLTg2NGVmNGIzMzM3MTwveG1wTU06SW5zdGFuY2VJRD4KICAgICAgICAgPHhtcE1NOkRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwveG1wTU06RG9jdW1lbnRJRD4KICAgICAgICAgPHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC94bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDx4bXBNTTpIaXN0b3J5PgogICAgICAgICAgICA8cmRmOlNlcT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+Y3JlYXRlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdEV2dDppbnN0YW5jZUlEPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6d2hlbj4yMDE0LTAxLTIyVDE5OjI0OjUxKzAxOjAwPC9zdEV2dDp3aGVuPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6c29mdHdhcmVBZ2VudD5BZG9iZSBQaG90b3Nob3AgQ1M2IChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPnNhdmVkPC9zdEV2dDphY3Rpb24+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDppbnN0YW5jZUlEPnhtcC5paWQ6RUE2MEEyNEUxOTg0RTMxMUFEQUZFRkU2RUMzMzNFMDM8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDEtMjNUMTk6MTg6MDcrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpPC9zdEV2dDpzb2Z0d2FyZUFnZW50PgogICAgICAgICAgICAgICAgICA8c3RFdnQ6Y2hhbmdlZD4vPC9zdEV2dDpjaGFuZ2VkPgogICAgICAgICAgICAgICA8L3JkZjpsaT4KICAgICAgICAgICAgICAgPHJkZjpsaSByZGY6cGFyc2VUeXBlPSJSZXNvdXJjZSI+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDphY3Rpb24+c2F2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0Omluc3RhbmNlSUQ+eG1wLmlpZDowNmE3NWYwMy04MDdhLWUzNGYtYjk1Zi1jZGU2MjM0Mzg4OGY8L3N0RXZ0Omluc3RhbmNlSUQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDp3aGVuPjIwMTQtMDItMDRUMTQ6NDE6MDQrMDE6MDA8L3N0RXZ0OndoZW4+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpzb2Z0d2FyZUFnZW50PkFkb2JlIFBob3Rvc2hvcCBDQyAoV2luZG93cyk8L3N0RXZ0OnNvZnR3YXJlQWdlbnQ+CiAgICAgICAgICAgICAgICAgIDxzdEV2dDpjaGFuZ2VkPi88L3N0RXZ0OmNoYW5nZWQ+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5jb252ZXJ0ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+ZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZzwvc3RFdnQ6cGFyYW1ldGVycz4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgICAgIDxyZGY6bGkgcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6YWN0aW9uPmRlcml2ZWQ8L3N0RXZ0OmFjdGlvbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnBhcmFtZXRlcnM+Y29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmc8L3N0RXZ0OnBhcmFtZXRlcnM+CiAgICAgICAgICAgICAgIDwvcmRmOmxpPgogICAgICAgICAgICAgICA8cmRmOmxpIHJkZjpwYXJzZVR5cGU9IlJlc291cmNlIj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmFjdGlvbj5zYXZlZDwvc3RFdnQ6YWN0aW9uPgogICAgICAgICAgICAgICAgICA8c3RFdnQ6aW5zdGFuY2VJRD54bXAuaWlkOjc3NDkzYmUxLTEyZGItOTg0NC1iNDYyLTg2NGVmNGIzMzM3MTwvc3RFdnQ6aW5zdGFuY2VJRD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OndoZW4+MjAxNC0wMi0wNFQxNDo0MTowNCswMTowMDwvc3RFdnQ6d2hlbj4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OnNvZnR3YXJlQWdlbnQ+QWRvYmUgUGhvdG9zaG9wIENDIChXaW5kb3dzKTwvc3RFdnQ6c29mdHdhcmVBZ2VudD4KICAgICAgICAgICAgICAgICAgPHN0RXZ0OmNoYW5nZWQ+Lzwvc3RFdnQ6Y2hhbmdlZD4KICAgICAgICAgICAgICAgPC9yZGY6bGk+CiAgICAgICAgICAgIDwvcmRmOlNlcT4KICAgICAgICAgPC94bXBNTTpIaXN0b3J5PgogICAgICAgICA8eG1wTU06RGVyaXZlZEZyb20gcmRmOnBhcnNlVHlwZT0iUmVzb3VyY2UiPgogICAgICAgICAgICA8c3RSZWY6aW5zdGFuY2VJRD54bXAuaWlkOjA2YTc1ZjAzLTgwN2EtZTM0Zi1iOTVmLWNkZTYyMzQzODg4Zjwvc3RSZWY6aW5zdGFuY2VJRD4KICAgICAgICAgICAgPHN0UmVmOmRvY3VtZW50SUQ+eG1wLmRpZDpFQTc2MkY5Njc0ODNFMzExOTQ4QkQxM0UyQkU3OTlBMTwvc3RSZWY6ZG9jdW1lbnRJRD4KICAgICAgICAgICAgPHN0UmVmOm9yaWdpbmFsRG9jdW1lbnRJRD54bXAuZGlkOjczQjYyQUFEOTE4M0UzMTE5NDhCRDEzRTJCRTc5OUExPC9zdFJlZjpvcmlnaW5hbERvY3VtZW50SUQ+CiAgICAgICAgIDwveG1wTU06RGVyaXZlZEZyb20+CiAgICAgICAgIDxwaG90b3Nob3A6Q29sb3JNb2RlPjM8L3Bob3Rvc2hvcDpDb2xvck1vZGU+CiAgICAgICAgIDxwaG90b3Nob3A6SUNDUHJvZmlsZT5zUkdCIElFQzYxOTY2LTIuMTwvcGhvdG9zaG9wOklDQ1Byb2ZpbGU+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyMDA5MC8xMDAwMDwvdGlmZjpYUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6WVJlc29sdXRpb24+NzIwMDkwLzEwMDAwPC90aWZmOllSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpSZXNvbHV0aW9uVW5pdD4yPC90aWZmOlJlc29sdXRpb25Vbml0PgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWERpbWVuc2lvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjI0PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz4aYJzYAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAYGSURBVHjalJZ7UJTnFcZ/73m/72PdJY1RbhoQp6lkXRAvmIYxdCUadLVOozPNtGObap1JsKipjiShbdoRbeKEiQHpQK3xj0xa03aamTbaTGyAYV1QGeqFi+JyiZFLAlmESBkWRmS3fyzslGkmnZ5/v/M873Oe75zzvqqoqAibzQaAiKCUAkApRdHIK/NFsx2NR91nOSILADDoJyzNaM4xxbtvPHh0iC+JiYkJ1OHDh4mJiUEpFSXPv/ziPC28TIiXDCOSrAClQDSEpsCwJPIhrEBRQpiSytXlQwDhcBilFPfu3UMVFxdjt9ujFTzfcLBADCoEEAFr1ZbrrNjch2vtEImPBgHob7fTcWE+bVXJNJ/NiFQlEGLvieXHKmYqGB8fRx05cgSbzYaIsPvywV8pKFaA7fGtLTzz61YWpo/xVTHQbufsq5lcez9zWuWhk5mvFwMEg0H0+vXrMU2Tn1wp3CtCiQ5DjGd3A/m/v8IDCZP8r4iNmyRrWx/j/5qktykZpXKzAjVDVxPzGqemptDr1q1jX3NRnIJarcDKK2hgR2ULXRfncv7UYv7xpovhnhiW5Mz+kefeSKO6LJ1A1xzEuk/Ojm4mRibpuZaMZW3OCtRUND60NmiICCIUShisx7a2sLMiQn4s77uEQgIabnqdfHIlgT1/qQeg8vs5dHhdCNB1wYn3RIiC995j26stjAbsNH+YiZJCESnS1Y/XxIXu8r4YIPv/VkVs3CTnTy2ms34xro1+sp9po6sxlTu34ultmsPVvy6is86FCHgO+DDs49zpjufBpCG+seYOC9OHaTidieicb9ouVAhKtouAseI710ma7pLuqwmgYfHqAFt+6WdLoQ/LBl11Lm7VudAa8vb72PCin9TlAWIsGGhLACD+kSAZnusYBii1XQAPYWDllt6ov2lrBkDBR2+6Ofuak2//3M+G/T4wAAPW7fPhKfRTVeqk9qQbFKRmDUTxS3N7QYGYmwzCkqklBGlPDEcTNv+sg9tNCbTXuvBWujE0bHrZj9JE1B/wU1Pm5PwJN6YBS9a2kVvQEcWnrh5GTFD3lxkYkqRMgYQlwVldUvDnen73LHTUuqitdKM0eAr9AFQfd1J/yo2aJn+2sn4Wdn5qEFODJskgBIjx5T0uCrQA08pnIjS9PERDjPnfOKXAMEBECUoGEIHBj+2zkt76UQ6dXheGAev3+cg74Kf6uJPqcicbfuond7cPy4SOiy7+tD9nFvZurx00KOk3CNEC+mE+vjSPBc7IWqgqTaPT60IMcO/xsXGa3HfKjRgRdbl7/KDg0jtubje6aHj7c7J3dgLQ2zoPwwQ91SooOQdAW1VKVMHty0kA5Bb48BycJn/LjWFGbLv4thvvb53kFvjJ+XEdWkPfjQVR/CcNKYgGMc8JWt5Fa2j+MIPPuyI2pa4IoHSkt6vLIuRaQ9q32khzt4GCxtNu6k46GeiIR2lIfDQQsafPzq1LGRGL9Gk9d+vrwewvfHPQOoexQVjxdB/auk/zmaUMdsfz6bVUtIalT7bxveP1ZHh6GPDPYeSzeD69kcpIfxymFWLNrka+ljhBTWkWwz2JiJT84YHnz2iPx0P20PkmRF5i6HYiwZFJsn/YzdezbzE3cQibY5xV266z6RfXohakb+xB9CjanCD9qTbW7Grk4WV38VZm0l6dhQiEw9taHSuDqrS0FIfDwXM3X9mHMsvRAk/sauDpQy38P+GtzOTGB9mEpkD0C2dS8n8zOjqK9ng8WJZFU+JTjasGvaCNXPpvJBPoMlm0OoDNMfWVxONfWNSUPUZ7TUQ56tCZlPwSgMnJSVRpaSmxsbFE1raw82ZxAZZRQUiBYUKGp5UlOX2krBzmoUVjiIKhHge9rfPo+Wcy3ZeXIYASgL1/X5RfMXMvj46OosrLy7HZbGitUUohIuzoem0RofALaOsghgWGjky0MiJTL8b0lOvI8hN1DKXKP0jd3TNTWDgcJhgMoo4ePYrD4Yi+KmaeLlprnrtXFo9h/AAlG1AqE8yFmBrC+jO0bgH9EVpO/1F2Dc5g//OAsbEx/j0Af+USsQynL1UAAAAASUVORK5CYII=")}div.vis-network div.vis-edit-mode div.vis-label,div.vis-network div.vis-manipulation div.vis-label{line-height:25px;margin:0 0 0 23px}div.vis-network div.vis-manipulation div.vis-separator-line{background-color:#bdbdbd;display:inline-block;float:left;height:21px;margin:0 7px 0 15px;width:1px}');var LO=function(){function g(t,A,e,C){var I,i,o=this;Us(this,g),this.body=t,this.canvas=A,this.selectionHandler=e,this.interactionHandler=C,this.editMode=!1,this.manipulationDiv=void 0,this.editModeDiv=void 0,this.closeDiv=void 0,this._domEventListenerCleanupQueue=[],this.temporaryUIFunctions={},this.temporaryEventFunctions=[],this.touchTime=0,this.temporaryIds={nodes:[],edges:[]},this.guiEnabled=!1,this.inMode=!1,this.selectedControlNode=void 0,this.options={},this.defaultOptions={enabled:!1,initiallyActive:!1,addNode:!0,addEdge:!0,editNode:void 0,editEdge:!0,deleteNode:!0,deleteEdge:!0,controlNodeStyle:{shape:"dot",size:6,color:{background:"#ff0000",border:"#3c3c3c",highlight:{background:"#07f968",border:"#3c3c3c"}},borderWidth:2,borderWidthSelected:2}},ae(this.options,this.defaultOptions),this.body.emitter.on("destroy",(function(){o._clean()})),this.body.emitter.on("_dataChanged",Me(I=this._restore).call(I,this)),this.body.emitter.on("_resetData",Me(i=this._restore).call(i,this))}return Xs(g,[{key:"_restore",value:function(){!1!==this.inMode&&(!0===this.options.initiallyActive?this.enableEditMode():this.disableEditMode())}},{key:"setOptions",value:function(g,t,A){void 0!==t&&(void 0!==t.locale?this.options.locale=t.locale:this.options.locale=A.locale,void 0!==t.locales?this.options.locales=t.locales:this.options.locales=A.locales),void 0!==g&&("boolean"==typeof g?this.options.enabled=g:(this.options.enabled=!0,Wp(this.options,g)),!0===this.options.initiallyActive&&(this.editMode=!0),this._setup())}},{key:"toggleEditMode",value:function(){!0===this.editMode?this.disableEditMode():this.enableEditMode()}},{key:"enableEditMode",value:function(){this.editMode=!0,this._clean(),!0===this.guiEnabled&&(this.manipulationDiv.style.display="block",this.closeDiv.style.display="block",this.editModeDiv.style.display="none",this.showManipulatorToolbar())}},{key:"disableEditMode",value:function(){this.editMode=!1,this._clean(),!0===this.guiEnabled&&(this.manipulationDiv.style.display="none",this.closeDiv.style.display="none",this.editModeDiv.style.display="block",this._createEditButton())}},{key:"showManipulatorToolbar",value:function(){if(this._clean(),this.manipulationDOM={},!0===this.guiEnabled){var g,t;this.editMode=!0,this.manipulationDiv.style.display="block",this.closeDiv.style.display="block";var A=this.selectionHandler.getSelectedNodeCount(),e=this.selectionHandler.getSelectedEdgeCount(),C=A+e,I=this.options.locales[this.options.locale],i=!1;!1!==this.options.addNode&&(this._createAddNodeButton(I),i=!0),!1!==this.options.addEdge&&(!0===i?this._createSeperator(1):i=!0,this._createAddEdgeButton(I)),1===A&&"function"==typeof this.options.editNode?(!0===i?this._createSeperator(2):i=!0,this._createEditNodeButton(I)):1===e&&0===A&&!1!==this.options.editEdge&&(!0===i?this._createSeperator(3):i=!0,this._createEditEdgeButton(I)),0!==C&&(A>0&&!1!==this.options.deleteNode||0===A&&!1!==this.options.deleteEdge)&&(!0===i&&this._createSeperator(4),this._createDeleteButton(I)),this._bindElementEvents(this.closeDiv,Me(g=this.toggleEditMode).call(g,this)),this._temporaryBindEvent("select",Me(t=this.showManipulatorToolbar).call(t,this))}this.body.emitter.emit("_redraw")}},{key:"addNodeMode",value:function(){var g;if(!0!==this.editMode&&this.enableEditMode(),this._clean(),this.inMode="addNode",!0===this.guiEnabled){var t,A=this.options.locales[this.options.locale];this.manipulationDOM={},this._createBackButton(A),this._createSeperator(),this._createDescription(A.addDescription||this.options.locales.en.addDescription),this._bindElementEvents(this.closeDiv,Me(t=this.toggleEditMode).call(t,this))}this._temporaryBindEvent("click",Me(g=this._performAddNode).call(g,this))}},{key:"editNode",value:function(){var g=this;!0!==this.editMode&&this.enableEditMode(),this._clean();var t=this.selectionHandler.getSelectedNodes()[0];if(void 0!==t){if(this.inMode="editNode","function"!=typeof this.options.editNode)throw new Error("No function has been configured to handle the editing of nodes.");if(!0!==t.isCluster){var A=Wp({},t.options,!1);if(A.x=t.x,A.y=t.y,2!==this.options.editNode.length)throw new Error("The function for edit does not support two arguments (data, callback)");this.options.editNode(A,(function(t){null!=t&&"editNode"===g.inMode&&g.body.data.nodes.getDataSet().update(t),g.showManipulatorToolbar()}))}else alert(this.options.locales[this.options.locale].editClusterError||this.options.locales.en.editClusterError)}else this.showManipulatorToolbar()}},{key:"addEdgeMode",value:function(){var g,t,A,e,C;if(!0!==this.editMode&&this.enableEditMode(),this._clean(),this.inMode="addEdge",!0===this.guiEnabled){var I,i=this.options.locales[this.options.locale];this.manipulationDOM={},this._createBackButton(i),this._createSeperator(),this._createDescription(i.edgeDescription||this.options.locales.en.edgeDescription),this._bindElementEvents(this.closeDiv,Me(I=this.toggleEditMode).call(I,this))}this._temporaryBindUI("onTouch",Me(g=this._handleConnect).call(g,this)),this._temporaryBindUI("onDragEnd",Me(t=this._finishConnect).call(t,this)),this._temporaryBindUI("onDrag",Me(A=this._dragControlNode).call(A,this)),this._temporaryBindUI("onRelease",Me(e=this._finishConnect).call(e,this)),this._temporaryBindUI("onDragStart",Me(C=this._dragStartEdge).call(C,this)),this._temporaryBindUI("onHold",(function(){}))}},{key:"editEdgeMode",value:function(){if(!0!==this.editMode&&this.enableEditMode(),this._clean(),this.inMode="editEdge","object"!==$a(this.options.editEdge)||"function"!=typeof this.options.editEdge.editWithoutDrag||(this.edgeBeingEditedId=this.selectionHandler.getSelectedEdgeIds()[0],void 0===this.edgeBeingEditedId)){if(!0===this.guiEnabled){var g,t=this.options.locales[this.options.locale];this.manipulationDOM={},this._createBackButton(t),this._createSeperator(),this._createDescription(t.editEdgeDescription||this.options.locales.en.editEdgeDescription),this._bindElementEvents(this.closeDiv,Me(g=this.toggleEditMode).call(g,this))}if(this.edgeBeingEditedId=this.selectionHandler.getSelectedEdgeIds()[0],void 0!==this.edgeBeingEditedId){var A,e,C,I,i=this.body.edges[this.edgeBeingEditedId],o=this._getNewTargetNode(i.from.x,i.from.y),n=this._getNewTargetNode(i.to.x,i.to.y);this.temporaryIds.nodes.push(o.id),this.temporaryIds.nodes.push(n.id),this.body.nodes[o.id]=o,this.body.nodeIndices.push(o.id),this.body.nodes[n.id]=n,this.body.nodeIndices.push(n.id),this._temporaryBindUI("onTouch",Me(A=this._controlNodeTouch).call(A,this)),this._temporaryBindUI("onTap",(function(){})),this._temporaryBindUI("onHold",(function(){})),this._temporaryBindUI("onDragStart",Me(e=this._controlNodeDragStart).call(e,this)),this._temporaryBindUI("onDrag",Me(C=this._controlNodeDrag).call(C,this)),this._temporaryBindUI("onDragEnd",Me(I=this._controlNodeDragEnd).call(I,this)),this._temporaryBindUI("onMouseMove",(function(){})),this._temporaryBindEvent("beforeDrawing",(function(g){var t=i.edgeType.findBorderPositions(g);!1===o.selected&&(o.x=t.from.x,o.y=t.from.y),!1===n.selected&&(n.x=t.to.x,n.y=t.to.y)})),this.body.emitter.emit("_redraw")}else this.showManipulatorToolbar()}else{var r=this.body.edges[this.edgeBeingEditedId];this._performEditEdge(r.from.id,r.to.id)}}},{key:"deleteSelected",value:function(){var g=this;!0!==this.editMode&&this.enableEditMode(),this._clean(),this.inMode="delete";var t=this.selectionHandler.getSelectedNodeIds(),A=this.selectionHandler.getSelectedEdgeIds(),e=void 0;if(t.length>0){for(var C=0;C<t.length;C++)if(!0===this.body.nodes[t[C]].isCluster)return void alert(this.options.locales[this.options.locale].deleteClusterError||this.options.locales.en.deleteClusterError);"function"==typeof this.options.deleteNode&&(e=this.options.deleteNode)}else A.length>0&&"function"==typeof this.options.deleteEdge&&(e=this.options.deleteEdge);if("function"==typeof e){var I={nodes:t,edges:A};if(2!==e.length)throw new Error("The function for delete does not support two arguments (data, callback)");e(I,(function(t){null!=t&&"delete"===g.inMode?(g.body.data.edges.getDataSet().remove(t.edges),g.body.data.nodes.getDataSet().remove(t.nodes),g.body.emitter.emit("startSimulation"),g.showManipulatorToolbar()):(g.body.emitter.emit("startSimulation"),g.showManipulatorToolbar())}))}else this.body.data.edges.getDataSet().remove(A),this.body.data.nodes.getDataSet().remove(t),this.body.emitter.emit("startSimulation"),this.showManipulatorToolbar()}},{key:"_setup",value:function(){!0===this.options.enabled?(this.guiEnabled=!0,this._createWrappers(),!1===this.editMode?this._createEditButton():this.showManipulatorToolbar()):(this._removeManipulationDOM(),this.guiEnabled=!1)}},{key:"_createWrappers",value:function(){var g,t;(void 0===this.manipulationDiv&&(this.manipulationDiv=document.createElement("div"),this.manipulationDiv.className="vis-manipulation",!0===this.editMode?this.manipulationDiv.style.display="block":this.manipulationDiv.style.display="none",this.canvas.frame.appendChild(this.manipulationDiv)),void 0===this.editModeDiv&&(this.editModeDiv=document.createElement("div"),this.editModeDiv.className="vis-edit-mode",!0===this.editMode?this.editModeDiv.style.display="none":this.editModeDiv.style.display="block",this.canvas.frame.appendChild(this.editModeDiv)),void 0===this.closeDiv)&&(this.closeDiv=document.createElement("button"),this.closeDiv.className="vis-close",this.closeDiv.setAttribute("aria-label",null!==(g=null===(t=this.options.locales[this.options.locale])||void 0===t?void 0:t.close)&&void 0!==g?g:this.options.locales.en.close),this.closeDiv.style.display=this.manipulationDiv.style.display,this.canvas.frame.appendChild(this.closeDiv))}},{key:"_getNewTargetNode",value:function(g,t){var A=Wp({},this.options.controlNodeStyle);A.id="targetNode"+cw(),A.hidden=!1,A.physics=!1,A.x=g,A.y=t;var e=this.body.functions.createNode(A);return e.shape.boundingBox={left:g,right:g,top:t,bottom:t},e}},{key:"_createEditButton",value:function(){var g;this._clean(),this.manipulationDOM={},Sp(this.editModeDiv);var t=this.options.locales[this.options.locale],A=this._createButton("editMode","vis-edit vis-edit-mode",t.edit||this.options.locales.en.edit);this.editModeDiv.appendChild(A),this._bindElementEvents(A,Me(g=this.toggleEditMode).call(g,this))}},{key:"_clean",value:function(){this.inMode=!1,!0===this.guiEnabled&&(Sp(this.editModeDiv),Sp(this.manipulationDiv),this._cleanupDOMEventListeners()),this._cleanupTemporaryNodesAndEdges(),this._unbindTemporaryUIs(),this._unbindTemporaryEvents(),this.body.emitter.emit("restorePhysics")}},{key:"_cleanupDOMEventListeners",value:function(){var g,t,A=FO(hh(g=this._domEventListenerCleanupQueue).call(g,0));try{for(A.s();!(t=A.n()).done;){(0,t.value)()}}catch(g){A.e(g)}finally{A.f()}}},{key:"_removeManipulationDOM",value:function(){this._clean(),Sp(this.manipulationDiv),Sp(this.editModeDiv),Sp(this.closeDiv),this.manipulationDiv&&this.canvas.frame.removeChild(this.manipulationDiv),this.editModeDiv&&this.canvas.frame.removeChild(this.editModeDiv),this.closeDiv&&this.canvas.frame.removeChild(this.closeDiv),this.manipulationDiv=void 0,this.editModeDiv=void 0,this.closeDiv=void 0}},{key:"_createSeperator",value:function(){var g=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1;this.manipulationDOM["seperatorLineDiv"+g]=document.createElement("div"),this.manipulationDOM["seperatorLineDiv"+g].className="vis-separator-line",this.manipulationDiv.appendChild(this.manipulationDOM["seperatorLineDiv"+g])}},{key:"_createAddNodeButton",value:function(g){var t,A=this._createButton("addNode","vis-add",g.addNode||this.options.locales.en.addNode);this.manipulationDiv.appendChild(A),this._bindElementEvents(A,Me(t=this.addNodeMode).call(t,this))}},{key:"_createAddEdgeButton",value:function(g){var t,A=this._createButton("addEdge","vis-connect",g.addEdge||this.options.locales.en.addEdge);this.manipulationDiv.appendChild(A),this._bindElementEvents(A,Me(t=this.addEdgeMode).call(t,this))}},{key:"_createEditNodeButton",value:function(g){var t,A=this._createButton("editNode","vis-edit",g.editNode||this.options.locales.en.editNode);this.manipulationDiv.appendChild(A),this._bindElementEvents(A,Me(t=this.editNode).call(t,this))}},{key:"_createEditEdgeButton",value:function(g){var t,A=this._createButton("editEdge","vis-edit",g.editEdge||this.options.locales.en.editEdge);this.manipulationDiv.appendChild(A),this._bindElementEvents(A,Me(t=this.editEdgeMode).call(t,this))}},{key:"_createDeleteButton",value:function(g){var t,A;A=this.options.rtl?"vis-delete-rtl":"vis-delete";var e=this._createButton("delete",A,g.del||this.options.locales.en.del);this.manipulationDiv.appendChild(e),this._bindElementEvents(e,Me(t=this.deleteSelected).call(t,this))}},{key:"_createBackButton",value:function(g){var t,A=this._createButton("back","vis-back",g.back||this.options.locales.en.back);this.manipulationDiv.appendChild(A),this._bindElementEvents(A,Me(t=this.showManipulatorToolbar).call(t,this))}},{key:"_createButton",value:function(g,t,A){var e=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"vis-label";return this.manipulationDOM[g+"Div"]=document.createElement("button"),this.manipulationDOM[g+"Div"].className="vis-button "+t,this.manipulationDOM[g+"Label"]=document.createElement("div"),this.manipulationDOM[g+"Label"].className=e,this.manipulationDOM[g+"Label"].innerText=A,this.manipulationDOM[g+"Div"].appendChild(this.manipulationDOM[g+"Label"]),this.manipulationDOM[g+"Div"]}},{key:"_createDescription",value:function(g){this.manipulationDOM.descriptionLabel=document.createElement("div"),this.manipulationDOM.descriptionLabel.className="vis-none",this.manipulationDOM.descriptionLabel.innerText=g,this.manipulationDiv.appendChild(this.manipulationDOM.descriptionLabel)}},{key:"_temporaryBindEvent",value:function(g,t){this.temporaryEventFunctions.push({event:g,boundFunction:t}),this.body.emitter.on(g,t)}},{key:"_temporaryBindUI",value:function(g,t){if(void 0===this.body.eventListeners[g])throw new Error("This UI function does not exist. Typo? You tried: "+g+" possible are: "+rc(yd(this.body.eventListeners)));this.temporaryUIFunctions[g]=this.body.eventListeners[g],this.body.eventListeners[g]=t}},{key:"_unbindTemporaryUIs",value:function(){for(var g in this.temporaryUIFunctions)Object.prototype.hasOwnProperty.call(this.temporaryUIFunctions,g)&&(this.body.eventListeners[g]=this.temporaryUIFunctions[g],delete this.temporaryUIFunctions[g]);this.temporaryUIFunctions={}}},{key:"_unbindTemporaryEvents",value:function(){for(var g=0;g<this.temporaryEventFunctions.length;g++){var t=this.temporaryEventFunctions[g].event,A=this.temporaryEventFunctions[g].boundFunction;this.body.emitter.off(t,A)}this.temporaryEventFunctions=[]}},{key:"_bindElementEvents",value:function(g,t){var A=new mf(g,{});sE(A,t),this._domEventListenerCleanupQueue.push((function(){A.destroy()}));var e=function(g){var A=g.keyCode,e=g.key;"Enter"!==e&&" "!==e&&13!==A&&32!==A||t()};g.addEventListener("keyup",e,!1),this._domEventListenerCleanupQueue.push((function(){g.removeEventListener("keyup",e,!1)}))}},{key:"_cleanupTemporaryNodesAndEdges",value:function(){for(var g=0;g<this.temporaryIds.edges.length;g++){var t;this.body.edges[this.temporaryIds.edges[g]].disconnect(),delete this.body.edges[this.temporaryIds.edges[g]];var A,e=zl(t=this.body.edgeIndices).call(t,this.temporaryIds.edges[g]);if(-1!==e)hh(A=this.body.edgeIndices).call(A,e,1)}for(var C=0;C<this.temporaryIds.nodes.length;C++){var I;delete this.body.nodes[this.temporaryIds.nodes[C]];var i,o=zl(I=this.body.nodeIndices).call(I,this.temporaryIds.nodes[C]);if(-1!==o)hh(i=this.body.nodeIndices).call(i,o,1)}this.temporaryIds={nodes:[],edges:[]}}},{key:"_controlNodeTouch",value:function(g){this.selectionHandler.unselectAll(),this.lastTouch=this.body.functions.getPointer(g.center),this.lastTouch.translation=ae({},this.body.view.translation)}},{key:"_controlNodeDragStart",value:function(){var g=this.lastTouch,t=this.selectionHandler._pointerToPositionObject(g),A=this.body.nodes[this.temporaryIds.nodes[0]],e=this.body.nodes[this.temporaryIds.nodes[1]],C=this.body.edges[this.edgeBeingEditedId];this.selectedControlNode=void 0;var I=A.isOverlappingWith(t),i=e.isOverlappingWith(t);!0===I?(this.selectedControlNode=A,C.edgeType.from=A):!0===i&&(this.selectedControlNode=e,C.edgeType.to=e),void 0!==this.selectedControlNode&&this.selectionHandler.selectObject(this.selectedControlNode),this.body.emitter.emit("_redraw")}},{key:"_controlNodeDrag",value:function(g){this.body.emitter.emit("disablePhysics");var t=this.body.functions.getPointer(g.center),A=this.canvas.DOMtoCanvas(t);void 0!==this.selectedControlNode?(this.selectedControlNode.x=A.x,this.selectedControlNode.y=A.y):this.interactionHandler.onDrag(g),this.body.emitter.emit("_redraw")}},{key:"_controlNodeDragEnd",value:function(g){var t=this.body.functions.getPointer(g.center),A=this.selectionHandler._pointerToPositionObject(t),e=this.body.edges[this.edgeBeingEditedId];if(void 0!==this.selectedControlNode){this.selectionHandler.unselectAll();for(var C=this.selectionHandler._getAllNodesOverlappingWith(A),I=void 0,i=C.length-1;i>=0;i--)if(C[i]!==this.selectedControlNode.id){I=this.body.nodes[C[i]];break}if(void 0!==I&&void 0!==this.selectedControlNode)if(!0===I.isCluster)alert(this.options.locales[this.options.locale].createEdgeError||this.options.locales.en.createEdgeError);else{var o=this.body.nodes[this.temporaryIds.nodes[0]];this.selectedControlNode.id===o.id?this._performEditEdge(I.id,e.to.id):this._performEditEdge(e.from.id,I.id)}else e.updateEdgeType(),this.body.emitter.emit("restorePhysics");this.body.emitter.emit("_redraw")}}},{key:"_handleConnect",value:function(g){if((new Date).valueOf()-this.touchTime>100){this.lastTouch=this.body.functions.getPointer(g.center),this.lastTouch.translation=ae({},this.body.view.translation),this.interactionHandler.drag.pointer=this.lastTouch,this.interactionHandler.drag.translation=this.lastTouch.translation;var t=this.lastTouch,A=this.selectionHandler.getNodeAt(t);if(void 0!==A)if(!0===A.isCluster)alert(this.options.locales[this.options.locale].createEdgeError||this.options.locales.en.createEdgeError);else{var e=this._getNewTargetNode(A.x,A.y);this.body.nodes[e.id]=e,this.body.nodeIndices.push(e.id);var C=this.body.functions.createEdge({id:"connectionEdge"+cw(),from:A.id,to:e.id,physics:!1,smooth:{enabled:!0,type:"continuous",roundness:.5}});this.body.edges[C.id]=C,this.body.edgeIndices.push(C.id),this.temporaryIds.nodes.push(e.id),this.temporaryIds.edges.push(C.id)}this.touchTime=(new Date).valueOf()}}},{key:"_dragControlNode",value:function(g){var t=this.body.functions.getPointer(g.center),A=this.selectionHandler._pointerToPositionObject(t),e=void 0;void 0!==this.temporaryIds.edges[0]&&(e=this.body.edges[this.temporaryIds.edges[0]].fromId);for(var C=this.selectionHandler._getAllNodesOverlappingWith(A),I=void 0,i=C.length-1;i>=0;i--){var o;if(-1===zl(o=this.temporaryIds.nodes).call(o,C[i])){I=this.body.nodes[C[i]];break}}if(g.controlEdge={from:e,to:I?I.id:void 0},this.selectionHandler.generateClickEvent("controlNodeDragging",g,t),void 0!==this.temporaryIds.nodes[0]){var n=this.body.nodes[this.temporaryIds.nodes[0]];n.x=this.canvas._XconvertDOMtoCanvas(t.x),n.y=this.canvas._YconvertDOMtoCanvas(t.y),this.body.emitter.emit("_redraw")}else this.interactionHandler.onDrag(g)}},{key:"_finishConnect",value:function(g){var t=this.body.functions.getPointer(g.center),A=this.selectionHandler._pointerToPositionObject(t),e=void 0;void 0!==this.temporaryIds.edges[0]&&(e=this.body.edges[this.temporaryIds.edges[0]].fromId);for(var C=this.selectionHandler._getAllNodesOverlappingWith(A),I=void 0,i=C.length-1;i>=0;i--){var o;if(-1===zl(o=this.temporaryIds.nodes).call(o,C[i])){I=this.body.nodes[C[i]];break}}this._cleanupTemporaryNodesAndEdges(),void 0!==I&&(!0===I.isCluster?alert(this.options.locales[this.options.locale].createEdgeError||this.options.locales.en.createEdgeError):void 0!==this.body.nodes[e]&&void 0!==this.body.nodes[I.id]&&this._performAddEdge(e,I.id)),g.controlEdge={from:e,to:I?I.id:void 0},this.selectionHandler.generateClickEvent("controlNodeDragEnd",g,t),this.body.emitter.emit("_redraw")}},{key:"_dragStartEdge",value:function(g){var t=this.lastTouch;this.selectionHandler.generateClickEvent("dragStart",g,t,void 0,!0)}},{key:"_performAddNode",value:function(g){var t=this,A={id:cw(),x:g.pointer.canvas.x,y:g.pointer.canvas.y,label:"new"};if("function"==typeof this.options.addNode){if(2!==this.options.addNode.length)throw this.showManipulatorToolbar(),new Error("The function for add does not support two arguments (data,callback)");this.options.addNode(A,(function(g){null!=g&&"addNode"===t.inMode&&t.body.data.nodes.getDataSet().add(g),t.showManipulatorToolbar()}))}else this.body.data.nodes.getDataSet().add(A),this.showManipulatorToolbar()}},{key:"_performAddEdge",value:function(g,t){var A=this,e={from:g,to:t};if("function"==typeof this.options.addEdge){if(2!==this.options.addEdge.length)throw new Error("The function for connect does not support two arguments (data,callback)");this.options.addEdge(e,(function(g){null!=g&&"addEdge"===A.inMode&&(A.body.data.edges.getDataSet().add(g),A.selectionHandler.unselectAll(),A.showManipulatorToolbar())}))}else this.body.data.edges.getDataSet().add(e),this.selectionHandler.unselectAll(),this.showManipulatorToolbar()}},{key:"_performEditEdge",value:function(g,t){var A=this,e={id:this.edgeBeingEditedId,from:g,to:t,label:this.body.data.edges.get(this.edgeBeingEditedId).label},C=this.options.editEdge;if("object"===$a(C)&&(C=C.editWithoutDrag),"function"==typeof C){if(2!==C.length)throw new Error("The function for edit does not support two arguments (data, callback)");C(e,(function(g){null==g||"editEdge"!==A.inMode?(A.body.edges[e.id].updateEdgeType(),A.body.emitter.emit("_redraw"),A.showManipulatorToolbar()):(A.body.data.edges.getDataSet().update(g),A.selectionHandler.unselectAll(),A.showManipulatorToolbar())}))}else this.body.data.edges.getDataSet().update(e),this.selectionHandler.unselectAll(),this.showManipulatorToolbar()}}]),g}(),jO="string",VO="boolean",YO="number",WO="array",QO="object",_O=["arrow","bar","box","circle","crow","curve","diamond","image","inv_curve","inv_triangle","triangle","vee"],UO={borderWidth:{number:YO},borderWidthSelected:{number:YO,undefined:"undefined"},brokenImage:{string:jO,undefined:"undefined"},chosen:{label:{boolean:VO,function:"function"},node:{boolean:VO,function:"function"},__type__:{object:QO,boolean:VO}},color:{border:{string:jO},background:{string:jO},highlight:{border:{string:jO},background:{string:jO},__type__:{object:QO,string:jO}},hover:{border:{string:jO},background:{string:jO},__type__:{object:QO,string:jO}},__type__:{object:QO,string:jO}},opacity:{number:YO,undefined:"undefined"},fixed:{x:{boolean:VO},y:{boolean:VO},__type__:{object:QO,boolean:VO}},font:{align:{string:jO},color:{string:jO},size:{number:YO},face:{string:jO},background:{string:jO},strokeWidth:{number:YO},strokeColor:{string:jO},vadjust:{number:YO},multi:{boolean:VO,string:jO},bold:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},boldital:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},ital:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},mono:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},__type__:{object:QO,string:jO}},group:{string:jO,number:YO,undefined:"undefined"},heightConstraint:{minimum:{number:YO},valign:{string:jO},__type__:{object:QO,boolean:VO,number:YO}},hidden:{boolean:VO},icon:{face:{string:jO},code:{string:jO},size:{number:YO},color:{string:jO},weight:{string:jO,number:YO},__type__:{object:QO}},id:{string:jO,number:YO},image:{selected:{string:jO,undefined:"undefined"},unselected:{string:jO,undefined:"undefined"},__type__:{object:QO,string:jO}},imagePadding:{top:{number:YO},right:{number:YO},bottom:{number:YO},left:{number:YO},__type__:{object:QO,number:YO}},label:{string:jO,undefined:"undefined"},labelHighlightBold:{boolean:VO},level:{number:YO,undefined:"undefined"},margin:{top:{number:YO},right:{number:YO},bottom:{number:YO},left:{number:YO},__type__:{object:QO,number:YO}},mass:{number:YO},physics:{boolean:VO},scaling:{min:{number:YO},max:{number:YO},label:{enabled:{boolean:VO},min:{number:YO},max:{number:YO},maxVisible:{number:YO},drawThreshold:{number:YO},__type__:{object:QO,boolean:VO}},customScalingFunction:{function:"function"},__type__:{object:QO}},shadow:{enabled:{boolean:VO},color:{string:jO},size:{number:YO},x:{number:YO},y:{number:YO},__type__:{object:QO,boolean:VO}},shape:{string:["custom","ellipse","circle","database","box","text","image","circularImage","diamond","dot","star","triangle","triangleDown","square","icon","hexagon"]},ctxRenderer:{function:"function"},shapeProperties:{borderDashes:{boolean:VO,array:WO},borderRadius:{number:YO},interpolation:{boolean:VO},useImageSize:{boolean:VO},useBorderWithImage:{boolean:VO},coordinateOrigin:{string:["center","top-left"]},__type__:{object:QO}},size:{number:YO},title:{string:jO,dom:"dom",undefined:"undefined"},value:{number:YO,undefined:"undefined"},widthConstraint:{minimum:{number:YO},maximum:{number:YO},__type__:{object:QO,boolean:VO,number:YO}},x:{number:YO},y:{number:YO},__type__:{object:QO}},HO={configure:{enabled:{boolean:VO},filter:{boolean:VO,string:jO,array:WO,function:"function"},container:{dom:"dom"},showButton:{boolean:VO},__type__:{object:QO,boolean:VO,string:jO,array:WO,function:"function"}},edges:{arrows:{to:{enabled:{boolean:VO},scaleFactor:{number:YO},type:{string:_O},imageHeight:{number:YO},imageWidth:{number:YO},src:{string:jO},__type__:{object:QO,boolean:VO}},middle:{enabled:{boolean:VO},scaleFactor:{number:YO},type:{string:_O},imageWidth:{number:YO},imageHeight:{number:YO},src:{string:jO},__type__:{object:QO,boolean:VO}},from:{enabled:{boolean:VO},scaleFactor:{number:YO},type:{string:_O},imageWidth:{number:YO},imageHeight:{number:YO},src:{string:jO},__type__:{object:QO,boolean:VO}},__type__:{string:["from","to","middle"],object:QO}},endPointOffset:{from:{number:YO},to:{number:YO},__type__:{object:QO,number:YO}},arrowStrikethrough:{boolean:VO},background:{enabled:{boolean:VO},color:{string:jO},size:{number:YO},dashes:{boolean:VO,array:WO},__type__:{object:QO,boolean:VO}},chosen:{label:{boolean:VO,function:"function"},edge:{boolean:VO,function:"function"},__type__:{object:QO,boolean:VO}},color:{color:{string:jO},highlight:{string:jO},hover:{string:jO},inherit:{string:["from","to","both"],boolean:VO},opacity:{number:YO},__type__:{object:QO,string:jO}},dashes:{boolean:VO,array:WO},font:{color:{string:jO},size:{number:YO},face:{string:jO},background:{string:jO},strokeWidth:{number:YO},strokeColor:{string:jO},align:{string:["horizontal","top","middle","bottom"]},vadjust:{number:YO},multi:{boolean:VO,string:jO},bold:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},boldital:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},ital:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},mono:{color:{string:jO},size:{number:YO},face:{string:jO},mod:{string:jO},vadjust:{number:YO},__type__:{object:QO,string:jO}},__type__:{object:QO,string:jO}},hidden:{boolean:VO},hoverWidth:{function:"function",number:YO},label:{string:jO,undefined:"undefined"},labelHighlightBold:{boolean:VO},length:{number:YO,undefined:"undefined"},physics:{boolean:VO},scaling:{min:{number:YO},max:{number:YO},label:{enabled:{boolean:VO},min:{number:YO},max:{number:YO},maxVisible:{number:YO},drawThreshold:{number:YO},__type__:{object:QO,boolean:VO}},customScalingFunction:{function:"function"},__type__:{object:QO}},selectionWidth:{function:"function",number:YO},selfReferenceSize:{number:YO},selfReference:{size:{number:YO},angle:{number:YO},renderBehindTheNode:{boolean:VO},__type__:{object:QO}},shadow:{enabled:{boolean:VO},color:{string:jO},size:{number:YO},x:{number:YO},y:{number:YO},__type__:{object:QO,boolean:VO}},smooth:{enabled:{boolean:VO},type:{string:["dynamic","continuous","discrete","diagonalCross","straightCross","horizontal","vertical","curvedCW","curvedCCW","cubicBezier"]},roundness:{number:YO},forceDirection:{string:["horizontal","vertical","none"],boolean:VO},__type__:{object:QO,boolean:VO}},title:{string:jO,undefined:"undefined"},width:{number:YO},widthConstraint:{maximum:{number:YO},__type__:{object:QO,boolean:VO,number:YO}},value:{number:YO,undefined:"undefined"},__type__:{object:QO}},groups:{useDefaultGroups:{boolean:VO},__any__:UO,__type__:{object:QO}},interaction:{dragNodes:{boolean:VO},dragView:{boolean:VO},hideEdgesOnDrag:{boolean:VO},hideEdgesOnZoom:{boolean:VO},hideNodesOnDrag:{boolean:VO},hover:{boolean:VO},keyboard:{enabled:{boolean:VO},speed:{x:{number:YO},y:{number:YO},zoom:{number:YO},__type__:{object:QO}},bindToWindow:{boolean:VO},autoFocus:{boolean:VO},__type__:{object:QO,boolean:VO}},multiselect:{boolean:VO},navigationButtons:{boolean:VO},selectable:{boolean:VO},selectConnectedEdges:{boolean:VO},hoverConnectedEdges:{boolean:VO},tooltipDelay:{number:YO},zoomView:{boolean:VO},zoomSpeed:{number:YO},__type__:{object:QO}},layout:{randomSeed:{undefined:"undefined",number:YO,string:jO},improvedLayout:{boolean:VO},clusterThreshold:{number:YO},hierarchical:{enabled:{boolean:VO},levelSeparation:{number:YO},nodeSpacing:{number:YO},treeSpacing:{number:YO},blockShifting:{boolean:VO},edgeMinimization:{boolean:VO},parentCentralization:{boolean:VO},direction:{string:["UD","DU","LR","RL"]},sortMethod:{string:["hubsize","directed"]},shakeTowards:{string:["leaves","roots"]},__type__:{object:QO,boolean:VO}},__type__:{object:QO}},manipulation:{enabled:{boolean:VO},initiallyActive:{boolean:VO},addNode:{boolean:VO,function:"function"},addEdge:{boolean:VO,function:"function"},editNode:{function:"function"},editEdge:{editWithoutDrag:{function:"function"},__type__:{object:QO,boolean:VO,function:"function"}},deleteNode:{boolean:VO,function:"function"},deleteEdge:{boolean:VO,function:"function"},controlNodeStyle:UO,__type__:{object:QO,boolean:VO}},nodes:UO,physics:{enabled:{boolean:VO},barnesHut:{theta:{number:YO},gravitationalConstant:{number:YO},centralGravity:{number:YO},springLength:{number:YO},springConstant:{number:YO},damping:{number:YO},avoidOverlap:{number:YO},__type__:{object:QO}},forceAtlas2Based:{theta:{number:YO},gravitationalConstant:{number:YO},centralGravity:{number:YO},springLength:{number:YO},springConstant:{number:YO},damping:{number:YO},avoidOverlap:{number:YO},__type__:{object:QO}},repulsion:{centralGravity:{number:YO},springLength:{number:YO},springConstant:{number:YO},nodeDistance:{number:YO},damping:{number:YO},__type__:{object:QO}},hierarchicalRepulsion:{centralGravity:{number:YO},springLength:{number:YO},springConstant:{number:YO},nodeDistance:{number:YO},damping:{number:YO},avoidOverlap:{number:YO},__type__:{object:QO}},maxVelocity:{number:YO},minVelocity:{number:YO},solver:{string:["barnesHut","repulsion","hierarchicalRepulsion","forceAtlas2Based"]},stabilization:{enabled:{boolean:VO},iterations:{number:YO},updateInterval:{number:YO},onlyDynamicEdges:{boolean:VO},fit:{boolean:VO},__type__:{object:QO,boolean:VO}},timestep:{number:YO},adaptiveTimestep:{boolean:VO},wind:{x:{number:YO},y:{number:YO},__type__:{object:QO}},__type__:{object:QO,boolean:VO}},autoResize:{boolean:VO},clickToUse:{boolean:VO},locale:{string:jO},locales:{__any__:{any:"any"},__type__:{object:QO}},height:{string:jO},width:{string:jO},__type__:{object:QO}},KO={nodes:{borderWidth:[1,0,10,1],borderWidthSelected:[2,0,10,1],color:{border:["color","#2B7CE9"],background:["color","#97C2FC"],highlight:{border:["color","#2B7CE9"],background:["color","#D2E5FF"]},hover:{border:["color","#2B7CE9"],background:["color","#D2E5FF"]}},opacity:[0,0,1,.1],fixed:{x:!1,y:!1},font:{color:["color","#343434"],size:[14,0,100,1],face:["arial","verdana","tahoma"],background:["color","none"],strokeWidth:[0,0,50,1],strokeColor:["color","#ffffff"]},hidden:!1,labelHighlightBold:!0,physics:!0,scaling:{min:[10,0,200,1],max:[30,0,200,1],label:{enabled:!1,min:[14,0,200,1],max:[30,0,200,1],maxVisible:[30,0,200,1],drawThreshold:[5,0,20,1]}},shadow:{enabled:!1,color:"rgba(0,0,0,0.5)",size:[10,0,20,1],x:[5,-30,30,1],y:[5,-30,30,1]},shape:["ellipse","box","circle","database","diamond","dot","square","star","text","triangle","triangleDown","hexagon"],shapeProperties:{borderDashes:!1,borderRadius:[6,0,20,1],interpolation:!0,useImageSize:!1},size:[25,0,200,1]},edges:{arrows:{to:{enabled:!1,scaleFactor:[1,0,3,.05],type:"arrow"},middle:{enabled:!1,scaleFactor:[1,0,3,.05],type:"arrow"},from:{enabled:!1,scaleFactor:[1,0,3,.05],type:"arrow"}},endPointOffset:{from:[0,-10,10,1],to:[0,-10,10,1]},arrowStrikethrough:!0,color:{color:["color","#848484"],highlight:["color","#848484"],hover:["color","#848484"],inherit:["from","to","both",!0,!1],opacity:[1,0,1,.05]},dashes:!1,font:{color:["color","#343434"],size:[14,0,100,1],face:["arial","verdana","tahoma"],background:["color","none"],strokeWidth:[2,0,50,1],strokeColor:["color","#ffffff"],align:["horizontal","top","middle","bottom"]},hidden:!1,hoverWidth:[1.5,0,5,.1],labelHighlightBold:!0,physics:!0,scaling:{min:[1,0,100,1],max:[15,0,100,1],label:{enabled:!0,min:[14,0,200,1],max:[30,0,200,1],maxVisible:[30,0,200,1],drawThreshold:[5,0,20,1]}},selectionWidth:[1.5,0,5,.1],selfReferenceSize:[20,0,200,1],selfReference:{size:[20,0,200,1],angle:[Math.PI/2,-6*Math.PI,6*Math.PI,Math.PI/8],renderBehindTheNode:!0},shadow:{enabled:!1,color:"rgba(0,0,0,0.5)",size:[10,0,20,1],x:[5,-30,30,1],y:[5,-30,30,1]},smooth:{enabled:!0,type:["dynamic","continuous","discrete","diagonalCross","straightCross","horizontal","vertical","curvedCW","curvedCCW","cubicBezier"],forceDirection:["horizontal","vertical","none"],roundness:[.5,0,1,.05]},width:[1,0,30,1]},layout:{hierarchical:{enabled:!1,levelSeparation:[150,20,500,5],nodeSpacing:[100,20,500,5],treeSpacing:[200,20,500,5],blockShifting:!0,edgeMinimization:!0,parentCentralization:!0,direction:["UD","DU","LR","RL"],sortMethod:["hubsize","directed"],shakeTowards:["leaves","roots"]}},interaction:{dragNodes:!0,dragView:!0,hideEdgesOnDrag:!1,hideEdgesOnZoom:!1,hideNodesOnDrag:!1,hover:!1,keyboard:{enabled:!1,speed:{x:[10,0,40,1],y:[10,0,40,1],zoom:[.02,0,.1,.005]},bindToWindow:!0,autoFocus:!0},multiselect:!1,navigationButtons:!1,selectable:!0,selectConnectedEdges:!0,hoverConnectedEdges:!0,tooltipDelay:[300,0,1e3,25],zoomView:!0,zoomSpeed:[1,.1,2,.1]},manipulation:{enabled:!1,initiallyActive:!1},physics:{enabled:!0,barnesHut:{theta:[.5,.1,1,.05],gravitationalConstant:[-2e3,-3e4,0,50],centralGravity:[.3,0,10,.05],springLength:[95,0,500,5],springConstant:[.04,0,1.2,.005],damping:[.09,0,1,.01],avoidOverlap:[0,0,1,.01]},forceAtlas2Based:{theta:[.5,.1,1,.05],gravitationalConstant:[-50,-500,0,1],centralGravity:[.01,0,1,.005],springLength:[95,0,500,5],springConstant:[.08,0,1.2,.005],damping:[.4,0,1,.01],avoidOverlap:[0,0,1,.01]},repulsion:{centralGravity:[.2,0,10,.05],springLength:[200,0,500,5],springConstant:[.05,0,1.2,.005],nodeDistance:[100,0,500,5],damping:[.09,0,1,.01]},hierarchicalRepulsion:{centralGravity:[.2,0,10,.05],springLength:[100,0,500,5],springConstant:[.01,0,1.2,.005],nodeDistance:[120,0,500,5],damping:[.09,0,1,.01],avoidOverlap:[0,0,1,.01]},maxVelocity:[50,0,150,1],minVelocity:[.1,.01,.5,.01],solver:["barnesHut","forceAtlas2Based","repulsion","hierarchicalRepulsion"],timestep:[.5,.01,1,.01],wind:{x:[0,-10,10,.1],y:[0,-10,10,.1]}}},XO=function(g,t,A){var e;return!(!zh(g).call(g,"physics")||!zh(e=KO.physics.solver).call(e,t)||A.physics.solver===t||"wind"===t)},JO=Object.freeze({__proto__:null,configuratorHideOption:XO,allOptions:HO,configureOptions:KO}),qO=function(){function g(){Us(this,g)}return Xs(g,[{key:"getDistances",value:function(g,t,A){for(var e={},C=g.edges,I=0;I<t.length;I++){var i={};e[t[I]]=i;for(var o=0;o<t.length;o++)i[t[o]]=I==o?0:1e9}for(var n=0;n<A.length;n++){var r=C[A[n]];!0===r.connected&&void 0!==e[r.fromId]&&void 0!==e[r.toId]&&(e[r.fromId][r.toId]=1,e[r.toId][r.fromId]=1)}for(var s=t.length,a=0;a<s;a++)for(var d=t[a],h=e[d],l=0;l<s-1;l++)for(var c=t[l],u=e[c],p=l+1;p<s;p++){var f=t[p],v=e[f],y=Math.min(u[f],u[d]+h[f]);u[f]=y,v[c]=y}return e}}]),g}(),$O=function(){function g(t,A,e){Us(this,g),this.body=t,this.springLength=A,this.springConstant=e,this.distanceSolver=new qO}return Xs(g,[{key:"setOptions",value:function(g){g&&(g.springLength&&(this.springLength=g.springLength),g.springConstant&&(this.springConstant=g.springConstant))}},{key:"solve",value:function(g,t){var A=arguments.length>2&&void 0!==arguments[2]&&arguments[2],e=this.distanceSolver.getDistances(this.body,g,t);this._createL_matrix(e),this._createK_matrix(e),this._createE_matrix();for(var C=.01,I=1,i=0,o=Math.max(1e3,Math.min(10*this.body.nodeIndices.length,6e3)),n=5,r=1e9,s=0,a=0,d=0,h=0,l=0;r>C&&i<o;){i+=1;var c=this._getHighestEnergyNode(A),u=Xa(c,4);for(s=u[0],r=u[1],a=u[2],d=u[3],h=r,l=0;h>I&&l<n;){l+=1,this._moveNode(s,a,d);var p=this._getEnergy(s),f=Xa(p,3);h=f[0],a=f[1],d=f[2]}}}},{key:"_getHighestEnergyNode",value:function(g){for(var t=this.body.nodeIndices,A=this.body.nodes,e=0,C=t[0],I=0,i=0,o=0;o<t.length;o++){var n=t[o];if(!0!==A[n].predefinedPosition||!0===A[n].isCluster&&!0===g||!0!==A[n].options.fixed.x||!0!==A[n].options.fixed.y){var r=Xa(this._getEnergy(n),3),s=r[0],a=r[1],d=r[2];e<s&&(e=s,C=n,I=a,i=d)}}return[C,e,I,i]}},{key:"_getEnergy",value:function(g){var t=Xa(this.E_sums[g],2),A=t[0],e=t[1];return[Math.sqrt(Math.pow(A,2)+Math.pow(e,2)),A,e]}},{key:"_moveNode",value:function(g,t,A){for(var e=this.body.nodeIndices,C=this.body.nodes,I=0,i=0,o=0,n=C[g].x,r=C[g].y,s=this.K_matrix[g],a=this.L_matrix[g],d=0;d<e.length;d++){var h=e[d];if(h!==g){var l=C[h].x,c=C[h].y,u=s[h],p=a[h],f=1/Math.pow(Math.pow(n-l,2)+Math.pow(r-c,2),1.5);I+=u*(1-p*Math.pow(r-c,2)*f),i+=u*(p*(n-l)*(r-c)*f),o+=u*(1-p*Math.pow(n-l,2)*f)}}var v=(t/I+A/i)/(i/I-o/i),y=-(i*v+t)/I;C[g].x+=y,C[g].y+=v,this._updateE_matrix(g)}},{key:"_createL_matrix",value:function(g){var t=this.body.nodeIndices,A=this.springLength;this.L_matrix=[];for(var e=0;e<t.length;e++){this.L_matrix[t[e]]={};for(var C=0;C<t.length;C++)this.L_matrix[t[e]][t[C]]=A*g[t[e]][t[C]]}}},{key:"_createK_matrix",value:function(g){var t=this.body.nodeIndices,A=this.springConstant;this.K_matrix=[];for(var e=0;e<t.length;e++){this.K_matrix[t[e]]={};for(var C=0;C<t.length;C++)this.K_matrix[t[e]][t[C]]=A*Math.pow(g[t[e]][t[C]],-2)}}},{key:"_createE_matrix",value:function(){var g=this.body.nodeIndices,t=this.body.nodes;this.E_matrix={},this.E_sums={};for(var A=0;A<g.length;A++)this.E_matrix[g[A]]=[];for(var e=0;e<g.length;e++){for(var C=g[e],I=t[C].x,i=t[C].y,o=0,n=0,r=e;r<g.length;r++){var s=g[r];if(s!==C){var a=t[s].x,d=t[s].y,h=1/Math.sqrt(Math.pow(I-a,2)+Math.pow(i-d,2));this.E_matrix[C][r]=[this.K_matrix[C][s]*(I-a-this.L_matrix[C][s]*(I-a)*h),this.K_matrix[C][s]*(i-d-this.L_matrix[C][s]*(i-d)*h)],this.E_matrix[s][e]=this.E_matrix[C][r],o+=this.E_matrix[C][r][0],n+=this.E_matrix[C][r][1]}}this.E_sums[C]=[o,n]}}},{key:"_updateE_matrix",value:function(g){for(var t=this.body.nodeIndices,A=this.body.nodes,e=this.E_matrix[g],C=this.K_matrix[g],I=this.L_matrix[g],i=A[g].x,o=A[g].y,n=0,r=0,s=0;s<t.length;s++){var a=t[s];if(a!==g){var d=e[s],h=d[0],l=d[1],c=A[a].x,u=A[a].y,p=1/Math.sqrt(Math.pow(i-c,2)+Math.pow(o-u,2)),f=C[a]*(i-c-I[a]*(i-c)*p),v=C[a]*(o-u-I[a]*(o-u)*p);e[s]=[f,v],n+=f,r+=v;var y=this.E_sums[a];y[0]+=f-h,y[1]+=v-l}}this.E_sums[g]=[n,r]}}]),g}();function gT(g,t,A){var e,C,I,i,o=this;if(!(this instanceof gT))throw new SyntaxError("Constructor must be called with the new operator");this.options={},this.defaultOptions={locale:"en",locales:iv,clickToUse:!1},ae(this.options,this.defaultOptions),this.body={container:g,nodes:{},nodeIndices:[],edges:{},edgeIndices:[],emitter:{on:Me(e=this.on).call(e,this),off:Me(C=this.off).call(C,this),emit:Me(I=this.emit).call(I,this),once:Me(i=this.once).call(i,this)},eventListeners:{onTap:function(){},onTouch:function(){},onDoubleTap:function(){},onHold:function(){},onDragStart:function(){},onDrag:function(){},onDragEnd:function(){},onMouseWheel:function(){},onPinch:function(){},onMouseMove:function(){},onRelease:function(){},onContext:function(){}},data:{nodes:null,edges:null},functions:{createNode:function(){},createEdge:function(){},getPointer:function(){}},modules:{},view:{scale:1,translation:{x:0,y:0}},selectionBox:{show:!1,position:{start:{x:0,y:0},end:{x:0,y:0}}}},this.bindEventListeners(),this.images=new nv((function(){return o.body.emitter.emit("_requestRedraw")})),this.groups=new Py,this.canvas=new dE(this.body),this.selectionHandler=new fO(this.body,this.canvas),this.interactionHandler=new vE(this.body,this.canvas,this.selectionHandler),this.view=new lE(this.body,this.canvas),this.renderer=new nE(this.body,this.canvas),this.physics=new tE(this.body),this.layoutEngine=new SO(this.body),this.clustering=new IE(this.body),this.manipulation=new LO(this.body,this.canvas,this.selectionHandler,this.interactionHandler),this.nodesHandler=new Hk(this.body,this.images,this.groups,this.layoutEngine),this.edgesHandler=new Wx(this.body,this.images,this.groups),this.body.modules.kamadaKawai=new $O(this.body,150,.05),this.body.modules.clustering=this.clustering,this.canvas._create(),this.setOptions(A),this.setData(t)}Ve(gT.prototype),gT.prototype.setOptions=function(g){var t=this;if(null===g&&(g=void 0),void 0!==g){!0===kf.validate(g,HO)&&console.error("%cErrors have been found in the supplied options object.",wf);if(Vp(["locale","locales","clickToUse"],this.options,g),void 0!==g.locale&&(g.locale=function(g,t){try{var A=Xa(t.split(/[-_ /]/,2),2),e=A[0],C=A[1],I=null!=e?e.toLowerCase():null,i=null!=C?C.toUpperCase():null;if(I&&i){var o,n=I+"-"+i;if(Object.prototype.hasOwnProperty.call(g,n))return n;console.warn(od(o="Unknown variant ".concat(i," of language ")).call(o,I,"."))}if(I){var r=I;if(Object.prototype.hasOwnProperty.call(g,r))return r;console.warn("Unknown language ".concat(I))}return console.warn("Unknown locale ".concat(t,", falling back to English.")),"en"}catch(g){return console.error(g),console.warn("Unexpected error while normalizing locale ".concat(t,", falling back to English.")),"en"}}(g.locales||this.options.locales,g.locale)),g=this.layoutEngine.setOptions(g.layout,g),this.canvas.setOptions(g),this.groups.setOptions(g.groups),this.nodesHandler.setOptions(g.nodes),this.edgesHandler.setOptions(g.edges),this.physics.setOptions(g.physics),this.manipulation.setOptions(g.manipulation,g,this.options),this.interactionHandler.setOptions(g.interaction),this.renderer.setOptions(g.interaction),this.selectionHandler.setOptions(g.interaction),void 0!==g.groups&&this.body.emitter.emit("refreshNodes"),"configure"in g&&(this.configurator||(this.configurator=new yf(this,this.body.container,KO,this.canvas.pixelRatio,XO)),this.configurator.setOptions(g.configure)),this.configurator&&!0===this.configurator.options.enabled){var A={nodes:{},edges:{},layout:{},interaction:{},manipulation:{},physics:{},global:{}};Wp(A.nodes,this.nodesHandler.options),Wp(A.edges,this.edgesHandler.options),Wp(A.layout,this.layoutEngine.options),Wp(A.interaction,this.selectionHandler.options),Wp(A.interaction,this.renderer.options),Wp(A.interaction,this.interactionHandler.options),Wp(A.manipulation,this.manipulation.options),Wp(A.physics,this.physics.options),Wp(A.global,this.canvas.options),Wp(A.global,this.options),this.configurator.setModuleOptions(A)}void 0!==g.clickToUse?!0===g.clickToUse?void 0===this.activator&&(this.activator=new vf(this.canvas.frame),this.activator.on("change",(function(){t.body.emitter.emit("activate")}))):(void 0!==this.activator&&(this.activator.destroy(),delete this.activator),this.body.emitter.emit("activate")):this.body.emitter.emit("activate"),this.canvas.setSize(),this.body.emitter.emit("startSimulation")}},gT.prototype._updateVisibleIndices=function(){var g=this.body.nodes,t=this.body.edges;for(var A in this.body.nodeIndices=[],this.body.edgeIndices=[],g)Object.prototype.hasOwnProperty.call(g,A)&&(this.clustering._isClusteredNode(A)||!1!==g[A].options.hidden||this.body.nodeIndices.push(g[A].id));for(var e in t)if(Object.prototype.hasOwnProperty.call(t,e)){var C=t[e],I=g[C.fromId],i=g[C.toId],o=void 0!==I&&void 0!==i;!this.clustering._isClusteredEdge(e)&&!1===C.options.hidden&&o&&!1===I.options.hidden&&!1===i.options.hidden&&this.body.edgeIndices.push(C.id)}},gT.prototype.bindEventListeners=function(){var g=this;this.body.emitter.on("_dataChanged",(function(){g.edgesHandler._updateState(),g.body.emitter.emit("_dataUpdated")})),this.body.emitter.on("_dataUpdated",(function(){g.clustering._updateState(),g._updateVisibleIndices(),g._updateValueRange(g.body.nodes),g._updateValueRange(g.body.edges),g.body.emitter.emit("startSimulation"),g.body.emitter.emit("_requestRedraw")}))},gT.prototype.setData=function(g){if(this.body.emitter.emit("resetPhysics"),this.body.emitter.emit("_resetData"),this.selectionHandler.unselectAll(),g&&g.dot&&(g.nodes||g.edges))throw new SyntaxError('Data must contain either parameter "dot" or  parameter pair "nodes" and "edges", but not both.');if(this.setOptions(g&&g.options),g&&g.dot){console.warn("The dot property has been deprecated. Please use the static convertDot method to convert DOT into vis.network format and use the normal data format with nodes and edges. This converter is used like this: var data = vis.network.convertDot(dotString);");var t=Av(g.dot);this.setData(t)}else if(g&&g.gephi){console.warn("The gephi property has been deprecated. Please use the static convertGephi method to convert gephi into vis.network format and use the normal data format with nodes and edges. This converter is used like this: var data = vis.network.convertGephi(gephiJson);");var A=Cv(g.gephi);this.setData(A)}else this.nodesHandler.setData(g&&g.nodes,!0),this.edgesHandler.setData(g&&g.edges,!0),this.body.emitter.emit("_dataChanged"),this.body.emitter.emit("_dataLoaded"),this.body.emitter.emit("initPhysics")},gT.prototype.destroy=function(){for(var g in this.body.emitter.emit("destroy"),this.body.emitter.off(),this.off(),delete this.groups,delete this.canvas,delete this.selectionHandler,delete this.interactionHandler,delete this.view,delete this.renderer,delete this.physics,delete this.layoutEngine,delete this.clustering,delete this.manipulation,delete this.nodesHandler,delete this.edgesHandler,delete this.configurator,delete this.images,this.body.nodes)Object.prototype.hasOwnProperty.call(this.body.nodes,g)&&delete this.body.nodes[g];for(var t in this.body.edges)Object.prototype.hasOwnProperty.call(this.body.edges,t)&&delete this.body.edges[t];Sp(this.body.container)},gT.prototype._updateValueRange=function(g){var t,A=void 0,e=void 0,C=0;for(t in g)if(Object.prototype.hasOwnProperty.call(g,t)){var I=g[t].getValue();void 0!==I&&(A=void 0===A?I:Math.min(I,A),e=void 0===e?I:Math.max(I,e),C+=I)}if(void 0!==A&&void 0!==e)for(t in g)Object.prototype.hasOwnProperty.call(g,t)&&g[t].setValueRange(A,e,C)},gT.prototype.isActive=function(){return!this.activator||this.activator.active},gT.prototype.setSize=function(){return this.canvas.setSize.apply(this.canvas,arguments)},gT.prototype.canvasToDOM=function(){return this.canvas.canvasToDOM.apply(this.canvas,arguments)},gT.prototype.DOMtoCanvas=function(){return this.canvas.DOMtoCanvas.apply(this.canvas,arguments)},gT.prototype.findNode=function(){return this.clustering.findNode.apply(this.clustering,arguments)},gT.prototype.isCluster=function(){return this.clustering.isCluster.apply(this.clustering,arguments)},gT.prototype.openCluster=function(){return this.clustering.openCluster.apply(this.clustering,arguments)},gT.prototype.cluster=function(){return this.clustering.cluster.apply(this.clustering,arguments)},gT.prototype.getNodesInCluster=function(){return this.clustering.getNodesInCluster.apply(this.clustering,arguments)},gT.prototype.clusterByConnection=function(){return this.clustering.clusterByConnection.apply(this.clustering,arguments)},gT.prototype.clusterByHubsize=function(){return this.clustering.clusterByHubsize.apply(this.clustering,arguments)},gT.prototype.updateClusteredNode=function(){return this.clustering.updateClusteredNode.apply(this.clustering,arguments)},gT.prototype.getClusteredEdges=function(){return this.clustering.getClusteredEdges.apply(this.clustering,arguments)},gT.prototype.getBaseEdge=function(){return this.clustering.getBaseEdge.apply(this.clustering,arguments)},gT.prototype.getBaseEdges=function(){return this.clustering.getBaseEdges.apply(this.clustering,arguments)},gT.prototype.updateEdge=function(){return this.clustering.updateEdge.apply(this.clustering,arguments)},gT.prototype.clusterOutliers=function(){return this.clustering.clusterOutliers.apply(this.clustering,arguments)},gT.prototype.getSeed=function(){return this.layoutEngine.getSeed.apply(this.layoutEngine,arguments)},gT.prototype.enableEditMode=function(){return this.manipulation.enableEditMode.apply(this.manipulation,arguments)},gT.prototype.disableEditMode=function(){return this.manipulation.disableEditMode.apply(this.manipulation,arguments)},gT.prototype.addNodeMode=function(){return this.manipulation.addNodeMode.apply(this.manipulation,arguments)},gT.prototype.editNode=function(){return this.manipulation.editNode.apply(this.manipulation,arguments)},gT.prototype.editNodeMode=function(){return console.warn("Deprecated: Please use editNode instead of editNodeMode."),this.manipulation.editNode.apply(this.manipulation,arguments)},gT.prototype.addEdgeMode=function(){return this.manipulation.addEdgeMode.apply(this.manipulation,arguments)},gT.prototype.editEdgeMode=function(){return this.manipulation.editEdgeMode.apply(this.manipulation,arguments)},gT.prototype.deleteSelected=function(){return this.manipulation.deleteSelected.apply(this.manipulation,arguments)},gT.prototype.getPositions=function(){return this.nodesHandler.getPositions.apply(this.nodesHandler,arguments)},gT.prototype.getPosition=function(){return this.nodesHandler.getPosition.apply(this.nodesHandler,arguments)},gT.prototype.storePositions=function(){return this.nodesHandler.storePositions.apply(this.nodesHandler,arguments)},gT.prototype.moveNode=function(){return this.nodesHandler.moveNode.apply(this.nodesHandler,arguments)},gT.prototype.getBoundingBox=function(){return this.nodesHandler.getBoundingBox.apply(this.nodesHandler,arguments)},gT.prototype.getConnectedNodes=function(g){return void 0!==this.body.nodes[g]?this.nodesHandler.getConnectedNodes.apply(this.nodesHandler,arguments):this.edgesHandler.getConnectedNodes.apply(this.edgesHandler,arguments)},gT.prototype.getConnectedEdges=function(){return this.nodesHandler.getConnectedEdges.apply(this.nodesHandler,arguments)},gT.prototype.startSimulation=function(){return this.physics.startSimulation.apply(this.physics,arguments)},gT.prototype.stopSimulation=function(){return this.physics.stopSimulation.apply(this.physics,arguments)},gT.prototype.stabilize=function(){return this.physics.stabilize.apply(this.physics,arguments)},gT.prototype.getSelection=function(){return this.selectionHandler.getSelection.apply(this.selectionHandler,arguments)},gT.prototype.setSelection=function(){return this.selectionHandler.setSelection.apply(this.selectionHandler,arguments)},gT.prototype.getSelectedNodes=function(){return this.selectionHandler.getSelectedNodeIds.apply(this.selectionHandler,arguments)},gT.prototype.getSelectedEdges=function(){return this.selectionHandler.getSelectedEdgeIds.apply(this.selectionHandler,arguments)},gT.prototype.getNodeAt=function(){var g=this.selectionHandler.getNodeAt.apply(this.selectionHandler,arguments);return void 0!==g&&void 0!==g.id?g.id:g},gT.prototype.getEdgeAt=function(){var g=this.selectionHandler.getEdgeAt.apply(this.selectionHandler,arguments);return void 0!==g&&void 0!==g.id?g.id:g},gT.prototype.selectNodes=function(){return this.selectionHandler.selectNodes.apply(this.selectionHandler,arguments)},gT.prototype.selectEdges=function(){return this.selectionHandler.selectEdges.apply(this.selectionHandler,arguments)},gT.prototype.unselectAll=function(){this.selectionHandler.unselectAll.apply(this.selectionHandler,arguments),this.selectionHandler.commitWithoutEmitting.apply(this.selectionHandler),this.redraw()},gT.prototype.redraw=function(){return this.renderer.redraw.apply(this.renderer,arguments)},gT.prototype.getScale=function(){return this.view.getScale.apply(this.view,arguments)},gT.prototype.getViewPosition=function(){return this.view.getViewPosition.apply(this.view,arguments)},gT.prototype.fit=function(){return this.view.fit.apply(this.view,arguments)},gT.prototype.moveTo=function(){return this.view.moveTo.apply(this.view,arguments)},gT.prototype.focus=function(){return this.view.focus.apply(this.view,arguments)},gT.prototype.releaseNode=function(){return this.view.releaseNode.apply(this.view,arguments)},gT.prototype.getOptionsFromConfigurator=function(){var g={};return this.configurator&&(g=this.configurator.getOptions.apply(this.configurator)),g};var tT=Av;g.DataSet=Ow,g.DataView=Tw,g.Network=gT,g.NetworkImages=nv,g.Queue=kw,g.data=Rw,g.networkDOTParser=ev,g.networkGephiParser=Iv,g.networkOptions=JO,g.parseDOTNetwork=tT,g.parseGephiNetwork=Cv,Object.defineProperty(g,"__esModule",{value:!0})}));
+
Index: /clusterlane/tags/1.3.2/admin/js/clusterlane-sticky-bar.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/clusterlane-sticky-bar.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/clusterlane-sticky-bar.js	(revision 3751027)
@@ -0,0 +1,448 @@
+/**
+ * Clusterlane Sticky Bar — JS
+ * Handles: article search, cluster dropdown population, quick actions (Scan/Sync/Metrics).
+ * Depends on clusterlaneData (restUrl, nonce, clusterInspectorUrl, articleInspectorUrl) from wp_localize_script.
+ * Version: injected via CLUSTERLANE_VERSION
+ */
+( function () {
+    'use strict';
+
+    const bar = document.getElementById( 'clusterlane-stickybar' );
+    if ( ! bar ) return;
+
+    // Deeper shadow once the bar is floating over scrolled content.
+    const onScroll = () => bar.classList.toggle( 'is-stuck', window.scrollY > 8 );
+    window.addEventListener( 'scroll', onScroll, { passive: true } );
+    onScroll();
+
+    // Use the sticky-bar-specific data object (always present on every Clusterlane page).
+    const cfg    = ( typeof clusterlaneStickyBar !== 'undefined' ) ? clusterlaneStickyBar : ( typeof clusterlaneData !== 'undefined' ? clusterlaneData : {} );
+    const rest   = cfg.restUrl  || '';
+    const nonce  = cfg.nonce    || '';
+    const aiUrl  = cfg.articleInspectorUrl || '';
+    const ciUrl  = cfg.clusterInspectorUrl || '';
+
+    /* ------------------------------------------------------------------
+       Toast helper
+    ------------------------------------------------------------------ */
+    // A light card under the action buttons: icon, message, optional
+    // "Reload" link; stays 7 s (paused while hovered) and can be closed.
+    const toast = document.getElementById( 'clusterlane-bar-toast' );
+    let toastTimer;
+    const G = ( n ) => ( typeof window.clusterlaneGlyph === 'function' ? window.clusterlaneGlyph( n ) : '' );
+    const esc = ( t ) => String( t ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
+    function hideToast() { if ( toast ) toast.hidden = true; }
+    function armToast( ms ) { clearTimeout( toastTimer ); toastTimer = setTimeout( hideToast, ms ); }
+    function showToast( msg, type = 'info', opts = {} ) {
+        if ( ! toast ) return;
+        const icon = { success: 'check', error: 'alert', info: 'refresh' }[ type ] || 'refresh';
+        toast.innerHTML = '<span class="clusterlane-stickybar__toast-icon">' + G( icon ) + '</span>'
+            + '<span class="clusterlane-stickybar__toast-msg">' + esc( msg )
+            + ( opts.reload ? ' <button type="button" class="clusterlane-stickybar__toast-link" data-reload>Reload to see the changes</button>' : '' ) + '</span>'
+            + ( opts.stop ? '<button type="button" class="clusterlane-stickybar__toast-stop" data-stop>Stop</button>' : '' )
+            + ( type !== 'info' ? '<button type="button" class="clusterlane-stickybar__toast-close" aria-label="Close">×</button>' : '' );
+        toast.className = 'clusterlane-stickybar__toast clusterlane-stickybar__toast--' + type;
+        toast.setAttribute( 'role', type === 'error' ? 'alert' : 'status' );
+        toast.hidden = false;
+        if ( type === 'info' ) clearTimeout( toastTimer ); else armToast( type === 'error' ? 10000 : 7000 );
+    }
+    if ( toast ) {
+        toast.addEventListener( 'mouseenter', () => clearTimeout( toastTimer ) );
+        toast.addEventListener( 'mouseleave', () => { if ( ! toast.hidden && ! toast.classList.contains( 'clusterlane-stickybar__toast--info' ) ) armToast( 3000 ); } );
+        toast.addEventListener( 'click', ( e ) => {
+            if ( e.target.closest( '[data-reload]' ) ) location.reload();
+            else if ( e.target.closest( '.clusterlane-stickybar__toast-close' ) ) hideToast();
+        } );
+    }
+    // Pages that show cluster data — offer a reload after Sync/Metrics.
+    const showsClusterData = /page=clusterlane-(dashboard|cluster-inspector|cluster-settings|article-inspector)\b/.test( location.search ) || /page=cci(&|$)/.test( location.search );
+
+    /* ------------------------------------------------------------------
+       Cluster dropdown — populate once on load
+    ------------------------------------------------------------------ */
+    const clusterSelect = document.getElementById( 'clusterlane-bar-cluster-select' );
+    if ( clusterSelect && rest ) {
+        fetch( rest + 'clusters', {
+            credentials: 'same-origin',
+            headers: { 'X-WP-Nonce': nonce }
+        } )
+            .then( r => r.ok ? r.json() : Promise.reject() )
+            .then( data => {
+                const clusters = data.clusters || [];
+                // Sort alphabetically by label
+                clusters.sort( ( a, b ) => ( a.cluster_label || '' ).localeCompare( b.cluster_label || '' ) );
+                clusters.forEach( c => {
+                    const opt = document.createElement( 'option' );
+                    opt.value       = c.cluster_key || '';
+                    opt.textContent = c.cluster_label || c.cluster_key;
+                    clusterSelect.appendChild( opt );
+                } );
+            } )
+            .catch( () => {} ); // silent — not critical
+    }
+    if ( clusterSelect ) {
+        clusterSelect.addEventListener( 'change', () => {
+            const key = clusterSelect.value;
+            if ( ! key || ! ciUrl ) return;
+            window.location.href = ciUrl + '&open=' + encodeURIComponent( key );
+            clusterSelect.value = ''; // reset so it looks like a jump control
+        } );
+    }
+
+    /* ------------------------------------------------------------------
+       Article search — debounced, uses clusterlane/v1/posts/search
+    ------------------------------------------------------------------ */
+    const articleInput   = document.getElementById( 'clusterlane-bar-article-input' );
+    const articleResults = document.getElementById( 'clusterlane-bar-article-results' );
+
+    let searchTimer;
+
+    function clearResults() {
+        if ( articleResults ) {
+            articleResults.innerHTML = '';
+            articleResults.hidden    = true;
+        }
+    }
+
+    function renderResults( posts ) {
+        if ( ! articleResults ) return;
+        articleResults.innerHTML = '';
+        if ( ! posts.length ) {
+            const li = document.createElement( 'li' );
+            li.className   = 'clusterlane-stickybar__dropdown-empty';
+            li.textContent = 'No results';
+            articleResults.appendChild( li );
+        } else {
+            posts.forEach( post => {
+                const li  = document.createElement( 'li' );
+                const a   = document.createElement( 'a' );
+                a.href        = aiUrl ? aiUrl + '&post_id=' + post.id : '#';
+                a.className   = 'clusterlane-stickybar__dropdown-item';
+                // Thumbnail + title; the title is set as text (never HTML).
+                const th = typeof window.clusterlaneThumb === 'function' ? window.clusterlaneThumb( post.thumb || '', post.title, '', 'md' ) : '';
+                if ( th ) a.insertAdjacentHTML( 'afterbegin', th );
+                const t = document.createElement( 'span' );
+                t.className = 'clusterlane-stickybar__dropdown-title';
+                t.textContent = post.title || '(no title)';
+                a.appendChild( t );
+                li.appendChild( a );
+                articleResults.appendChild( li );
+            } );
+        }
+        articleResults.hidden = false;
+    }
+
+    if ( articleInput ) {
+        articleInput.addEventListener( 'input', () => {
+            const q = articleInput.value.trim();
+            clearTimeout( searchTimer );
+            if ( q.length < 2 ) { clearResults(); return; }
+            searchTimer = setTimeout( () => {
+                fetch( rest + 'posts/search?q=' + encodeURIComponent( q ), {
+                    credentials: 'same-origin',
+                    headers: { 'X-WP-Nonce': nonce }
+                } )
+                    .then( r => r.ok ? r.json() : r.json().then( e => Promise.reject(e) ) )
+                    .then( data => renderResults( data.posts || [] ) )
+                    .catch( err => { console.warn( '[Clusterlane sticky bar] article search error', err ); clearResults(); } );
+            }, 220 );
+        } );
+
+        // Close on blur (small delay so click on result registers)
+        articleInput.addEventListener( 'blur', () => {
+            setTimeout( clearResults, 300 );
+        } );
+
+        // Close on Escape
+        articleInput.addEventListener( 'keydown', e => {
+            if ( e.key === 'Escape' ) { clearResults(); articleInput.blur(); }
+        } );
+    }
+
+    // Close results on outside click
+    document.addEventListener( 'click', e => {
+        const wrap = document.getElementById( 'clusterlane-bar-article-wrap' );
+        if ( wrap && ! wrap.contains( e.target ) ) clearResults();
+    } );
+
+    /* ------------------------------------------------------------------
+       Quick actions — Scan / Sync / Metrics
+       Reuse the same fetch helpers as dashboard.js where available,
+       otherwise call REST endpoints directly.
+    ------------------------------------------------------------------ */
+    function setButtonBusy( btn, busy, busyLabel ) {
+        if ( ! btn ) return;
+        btn.disabled = busy;
+        const icon = btn.querySelector( '.dashicons' );
+        if ( icon ) icon.classList.toggle( 'clusterlane-spin', busy );
+        const label = btn.querySelector( '.clusterlane-stickybar__action-label' );
+        if ( label ) {
+            if ( ! btn.dataset.label ) btn.dataset.label = label.textContent;
+            label.textContent = busy && busyLabel ? busyLabel : btn.dataset.label;
+        }
+        btn.classList.toggle( 'is-busy', busy );
+        btn.setAttribute( 'aria-busy', busy ? 'true' : 'false' );
+    }
+    // The button itself confirms the result for a few seconds (green ✓ / red).
+    function flashButton( btn, ok, text ) {
+        if ( ! btn ) return;
+        const label = btn.querySelector( '.clusterlane-stickybar__action-label' );
+        const icon  = btn.querySelector( '.dashicons' );
+        btn.classList.remove( 'is-done', 'is-failed' );
+        btn.classList.add( ok ? 'is-done' : 'is-failed' );
+        if ( label ) label.textContent = text;
+        if ( icon ) { btn.dataset.icon = btn.dataset.icon || icon.className; icon.className = 'dashicons ' + ( ok ? 'dashicons-yes' : 'dashicons-warning' ); }
+        clearTimeout( btn._flash );
+        btn._flash = setTimeout( () => {
+            btn.classList.remove( 'is-done', 'is-failed' );
+            if ( label ) label.textContent = btn.dataset.label || label.textContent;
+            if ( icon && btn.dataset.icon ) icon.className = btn.dataset.icon;
+        }, 3000 );
+    }
+
+    // Scan — the whole pipeline runs here, on every page:
+    //   1. list the articles (scan/posts)
+    //   2. scan them in batches (scan/batch); a failing batch is split and
+    //      retried down to single posts, so nothing is silently dropped
+    //   3. compute metrics (clusters/compute)
+    // The button shows the percentage, a thin bar runs along the dock, the
+    // card under the buttons has Stop, and leaving the page asks first.
+    const scanBtn = document.getElementById( 'clusterlane-bar-scan' );
+    const barEl   = document.getElementById( 'clusterlane-stickybar' );
+    let scanning  = null;   // { stop() }
+    function post( path, body ) {
+        return fetch( rest + path, {
+            method: 'POST', credentials: 'same-origin',
+            headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json' },
+            body: JSON.stringify( body || {} ),
+        } ).then( r => r.json().catch( () => ( {} ) ).then( d => ( r.ok ? d : Promise.reject( new Error( d.error || ( 'HTTP ' + r.status ) ) ) ) ) );
+    }
+    function setScanProgress( pct ) {
+        if ( ! barEl ) return;
+        barEl.classList.toggle( 'is-scanning', pct !== null );
+        barEl.style.setProperty( '--clusterlane-scan-pct', ( pct || 0 ) + '%' );
+    }
+    function scanLabel( t ) { const l = scanBtn && scanBtn.querySelector( '.clusterlane-stickybar__action-label' ); if ( l ) l.textContent = t; }
+
+    function runScan() {
+        const batchSize = parseInt( cfg.batchSize, 10 ) || 25;
+        let stopped = false, total = 0, done = 0, scanned = 0, created = 0; const failed = []; const queue = [];
+        const pages = { ok: 0, failed: 0, example: null }; // published pages read (Settings → Content, 0.98.177)
+        let queued = 0;                                     // published pages left to read in the background (0.98.178)
+        stopReadingPages();                                 // a new scan starts a new queue
+        setButtonBusy( scanBtn, true, 'Scanning…' );
+        setScanProgress( 0 );
+        showToast( 'Loading the list of articles…', 'info', { stop: true } );
+
+        fetch( rest + 'scan/posts', { headers: { 'X-WP-Nonce': nonce }, credentials: 'same-origin' } )
+            .then( r => r.json().catch( () => ( {} ) ).then( d => ( r.ok ? d : Promise.reject( new Error( d.error || ( 'HTTP ' + r.status ) ) ) ) ) )
+            .then( ( res ) => {
+                const ids = res.ids || [];
+                total = ids.length;
+                if ( ! total ) { finish( 'none' ); return; }
+                for ( let i = 0; i < ids.length; i += batchSize ) queue.push( ids.slice( i, i + batchSize ) );
+                next();
+            } )
+            .catch( ( e ) => finish( 'error', e ) );
+
+        function progress() {
+            const pct = total ? Math.round( done / total * 100 ) : 0;
+            setScanProgress( pct ); scanLabel( pct + '%' );
+            showToast( 'Scanning articles — ' + done + ' of ' + total + ' done.', 'info', { stop: true } );
+        }
+        function next() {
+            if ( stopped ) return;
+            if ( ! queue.length ) { computeThenFinish(); return; }
+            const batch = queue.shift(); progress();
+            let settled = false;
+            const timer = setTimeout( () => { if ( ! settled ) { settled = true; fail(); } }, 60000 );
+            function fail() {
+                if ( batch.length > 1 ) { const mid = Math.ceil( batch.length / 2 ); queue.unshift( batch.slice( mid ) ); queue.unshift( batch.slice( 0, mid ) ); }
+                else { failed.push( batch[ 0 ] ); done += 1; }
+                setTimeout( next, 150 );
+            }
+            post( 'scan/batch', { ids: batch, pages: 'later' } ) // published pages: read afterwards, in the background (0.98.178)
+                .then( ( r ) => {
+                    if ( settled ) return; settled = true; clearTimeout( timer );
+                    // A batch that reads published pages stops after ~20 s and hands the rest back.
+                    const rest = Array.isArray( r.remaining ) ? r.remaining.map( Number ).filter( Boolean ) : [];
+                    scanned += parseInt( r.scanned, 10 ) || 0; done += batch.length - rest.length;
+                    if ( rest.length ) queue.unshift( rest );
+                    if ( r.pages ) { pages.ok += +r.pages.ok || 0; pages.failed += +r.pages.failed || 0; if ( ! pages.example && r.pages.example ) pages.example = r.pages.example; }
+                    queued += parseInt( r.queued, 10 ) || 0;
+                    setTimeout( next, 40 );
+                } )
+                .catch( () => { if ( settled ) return; settled = true; clearTimeout( timer ); fail(); } );
+        }
+        function computeThenFinish() {
+            setScanProgress( 100 ); scanLabel( 'Metrics…' );
+            showToast( 'All ' + total + ' articles scanned — updating clusters and metrics…', 'info' );
+            // Sync first, so new categories/tags (or a brand-new site) get their clusters.
+            post( 'clusters/sync' ).catch( () => ( {} ) )
+                .then( ( s ) => { created = parseInt( s && s.created, 10 ) || 0; return post( 'clusters/compute' ); } )
+                .then( ( d ) => finish( 'done', null, parseInt( d.computed, 10 ) || 0 ) )
+                .catch( ( e ) => finish( 'metrics-failed', e ) );
+        }
+        function finish( state, err, computed ) {
+            scanning = null; setScanProgress( null ); setButtonBusy( scanBtn, false );
+            const lastScan = document.querySelector( '.clusterlane-hero-lastscan' );
+            const skipped = ( failed.length ? ' ' + failed.length + ' could not be scanned (IDs ' + failed.slice( 0, 8 ).join( ', ' ) + ( failed.length > 8 ? '…' : '' ) + ').' : '' )
+                + ( pages.failed ? ' ' + pages.failed + ' of ' + ( pages.ok + pages.failed ) + ' published pages could not be read' + ( pages.example && pages.example.message ? ' (' + pages.example.message + ')' : '' ) + ' — their text links still count. Details: Settings → Content.' : '' );
+            if ( state === 'done' ) {
+                flashButton( scanBtn, ! failed.length && ! pages.failed, failed.length || pages.failed ? 'Partly' : 'Scanned' );
+                if ( lastScan ) lastScan.textContent = 'Last scan: just now';
+                if ( queued ) {
+                    showToast( 'Articles scanned, metrics updated for ' + computed + ' cluster' + ( computed === 1 ? '' : 's' ) + '. Now reading ' + queued + ' published page' + ( queued === 1 ? '' : 's' ) + ' in the background — keep working; the numbers update when it’s done.' + skipped, failed.length ? 'error' : 'success' );
+                    readPages();
+                } else {
+                    showToast( 'Scan complete — ' + scanned + ' article' + ( scanned === 1 ? '' : 's' ) + ' scanned, metrics updated for ' + computed + ' cluster' + ( computed === 1 ? '' : 's' ) + ( created ? ' (' + created + ' new)' : '' ) + '.' + skipped, failed.length || pages.failed ? 'error' : 'success', { reload: true } );
+                }
+            } else if ( state === 'stopped' ) {
+                flashButton( scanBtn, false, 'Stopped' );
+                showToast( 'Scan stopped after ' + done + ' of ' + total + ' articles. Run it again to finish.', 'error' );
+            } else if ( state === 'none' ) {
+                flashButton( scanBtn, false, 'Nothing' );
+                showToast( 'No published articles to scan.', 'error' );
+            } else if ( state === 'metrics-failed' ) {
+                flashButton( scanBtn, false, 'Partly' );
+                showToast( 'Articles scanned, but computing metrics failed: ' + ( err && err.message ? err.message : 'unknown error' ) + '. Click Scan to try again.', 'error', { reload: true } );
+            } else {
+                flashButton( scanBtn, false, 'Failed' );
+                showToast( 'Scan failed: ' + ( err && err.message ? err.message : 'unknown error' ), 'error' );
+            }
+        }
+        return { stop() { stopped = true; finish( 'stopped' ); } };
+    }
+    /* ------------------------------------------------------------------
+       Published pages read in the background (0.98.178)
+       A scan queues the pages; this reads them ~20 s at a time while any
+       Clusterlane screen is open (WP-Cron takes over when none is), shows the
+       progress next to the Scan button and says when the numbers are updated.
+    ------------------------------------------------------------------ */
+    let pagesTimer = null, pagesOn = false, pagesPill = null;
+    function pagesLabel( st ) {
+        if ( ! pagesPill ) {
+            const actions = bar.querySelector( '.clusterlane-stickybar__actions' );
+            if ( ! actions ) return;
+            pagesPill = document.createElement( 'span' );
+            pagesPill.className = 'clusterlane-stickybar__pages';
+            pagesPill.title = 'Reading the published pages found by the last scan (Settings → Content). You can keep working or leave — it carries on in the background.';
+            actions.insertBefore( pagesPill, actions.firstChild );
+        }
+        const pct = st.total ? Math.round( st.done / st.total * 100 ) : 0;
+        pagesPill.innerHTML = G( 'refresh' ) + '<span>Pages ' + esc( st.done ) + '/' + esc( st.total ) + '</span>';
+        pagesPill.style.setProperty( '--clusterlane-pages-pct', pct + '%' );
+        pagesPill.hidden = false;
+    }
+    function stopReadingPages() {
+        pagesOn = false; clearTimeout( pagesTimer );
+        if ( pagesPill ) pagesPill.hidden = true;
+    }
+    function readPages() {
+        if ( pagesOn || ! rest ) return;
+        pagesOn = true;
+        const step = () => {
+            if ( ! pagesOn ) return;
+            post( 'scan/pages' )
+                .then( ( st ) => {
+                    if ( ! pagesOn ) return;
+                    if ( st.left ) { pagesLabel( st ); pagesTimer = setTimeout( step, st.busy ? 5000 : 300 ); return; }
+                    stopReadingPages();
+                    if ( ! st.total ) return;
+                    const bad = st.failed ? ' ' + st.failed + ' could not be read' + ( st.example && st.example.message ? ' (' + st.example.message + ')' : '' ) + ' — their text links still count. Details: Settings → Content.' : '';
+                    showToast( 'Published pages read — ' + st.ok + ' of ' + st.total + '. Links and metrics are updated.' + bad, st.failed ? 'error' : 'success', { reload: showsClusterData } );
+                } )
+                .catch( () => { if ( pagesOn ) pagesTimer = setTimeout( step, 10000 ); } );
+        };
+        step();
+    }
+    window.clusterlaneReadPages = readPages;
+    if ( cfg.pages && cfg.pages.left ) { pagesLabel( cfg.pages ); readPages(); } // queued before this screen opened
+
+    if ( scanBtn ) {
+        scanBtn.addEventListener( 'click', () => { if ( ! scanning ) scanning = runScan(); } );
+        window.clusterlaneStartScan = () => { if ( ! scanning ) scanning = runScan(); };
+    }
+    if ( toast ) toast.addEventListener( 'click', ( e ) => { if ( e.target.closest( '[data-stop]' ) && scanning ) scanning.stop(); } );
+    window.addEventListener( 'beforeunload', ( e ) => { if ( scanning ) { e.preventDefault(); e.returnValue = ''; } } );
+
+    // Sync
+    const syncBtn = document.getElementById( 'clusterlane-bar-sync' );
+    if ( syncBtn ) {
+        syncBtn.addEventListener( 'click', () => {
+            setButtonBusy( syncBtn, true, 'Syncing…' );
+            showToast( 'Syncing clusters with your categories and tags…', 'info' );
+            fetch( rest + 'clusters/sync', {
+                method:  'POST',
+                headers: { 'X-WP-Nonce': nonce },
+            } )
+                .then( r => r.json().catch( () => ( {} ) ).then( d => ( r.ok ? d : Promise.reject( new Error( d.error || ( 'HTTP ' + r.status ) ) ) ) ) )
+                .then( ( d ) => {
+                    const n = parseInt( d.created, 10 ) || 0;
+                    setButtonBusy( syncBtn, false ); flashButton( syncBtn, true, 'Synced' );
+                    showToast( n ? 'Clusters synced — ' + n + ' new cluster' + ( n === 1 ? '' : 's' ) + '.' : 'Clusters synced — already up to date.', 'success', { reload: showsClusterData && n > 0 } );
+                } )
+                .catch( ( e ) => { setButtonBusy( syncBtn, false ); flashButton( syncBtn, false, 'Failed' ); showToast( 'Sync failed: ' + ( e && e.message ? e.message : 'unknown error' ), 'error' ); } );
+        } );
+    }
+
+    // Metrics
+    const metricsBtn = document.getElementById( 'clusterlane-bar-metrics' );
+    if ( metricsBtn ) {
+        metricsBtn.addEventListener( 'click', () => {
+            setButtonBusy( metricsBtn, true, 'Computing…' );
+            showToast( 'Computing health, roles and archetypes for every cluster…', 'info' );
+            fetch( rest + 'clusters/compute', {
+                method:  'POST',
+                headers: { 'X-WP-Nonce': nonce },
+            } )
+                .then( r => r.json().catch( () => ( {} ) ).then( d => ( r.ok ? d : Promise.reject( new Error( d.error || ( 'HTTP ' + r.status ) ) ) ) ) )
+                .then( ( d ) => {
+                    const n = parseInt( d.computed, 10 ) || 0;
+                    setButtonBusy( metricsBtn, false ); flashButton( metricsBtn, true, 'Updated' );
+                    showToast( 'Metrics updated' + ( n ? ' for ' + n + ' cluster' + ( n === 1 ? '' : 's' ) : '' ) + '.', 'success', { reload: showsClusterData } );
+                } )
+                .catch( ( e ) => { setButtonBusy( metricsBtn, false ); flashButton( metricsBtn, false, 'Failed' ); showToast( 'Computing metrics failed: ' + ( e && e.message ? e.message : 'unknown error' ), 'error' ); } );
+        } );
+    }
+
+    /* ------------------------------------------------------------------
+       Tabs that don't fit (0.98.186) — instead of a row that scrolls
+       sideways with a hidden scrollbar (Help was cut at 1440 px), the tabs
+       from the end move into "More" — always the same ones (0.98.192).
+    ------------------------------------------------------------------ */
+    const nav      = bar.querySelector( '.clusterlane-stickybar__nav' );
+    const more     = document.getElementById( 'clusterlane-bar-more' );
+    const moreBtn  = more && more.querySelector( '.clusterlane-stickybar__more-btn' );
+    const moreMenu = document.getElementById( 'clusterlane-bar-more-menu' );
+    function closeMore() { if ( moreMenu ) { moreMenu.hidden = true; moreBtn.setAttribute( 'aria-expanded', 'false' ); } }
+    // The fitting itself runs inline in the bar's markup, before the first paint (class-subnav.php).
+    const fitTabs = () => { if ( typeof window.clusterlaneFitTabs === 'function' ) window.clusterlaneFitTabs(); };
+    if ( moreBtn ) {
+        moreBtn.addEventListener( 'click', e => {
+            e.stopPropagation();
+            const open = moreMenu.hidden;
+            moreMenu.hidden = ! open; moreBtn.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
+            // Right edge under the button's right edge, so the menu stays over the bar.
+            if ( open ) { const r = moreBtn.getBoundingClientRect(); moreMenu.style.left = Math.max( 8, r.right - moreMenu.offsetWidth ) + 'px'; moreMenu.style.top = ( r.bottom + 6 ) + 'px'; }
+        } );
+        document.addEventListener( 'click', e => { if ( ! more.contains( e.target ) ) closeMore(); } );
+        document.addEventListener( 'keydown', e => { if ( e.key === 'Escape' && ! moreMenu.hidden ) { closeMore(); moreBtn.focus(); } } );
+        window.addEventListener( 'scroll', closeMore, { passive: true } );
+    }
+    let fitTimer = null;
+    window.addEventListener( 'resize', () => { clearTimeout( fitTimer ); fitTimer = setTimeout( fitTabs, 80 ); } );
+    if ( document.fonts && document.fonts.ready ) document.fonts.ready.then( fitTabs );
+
+    /* ------------------------------------------------------------------
+       Sticky offset — push wp-admin's #wpcontent down so the bar
+       doesn't overlap the first content element.
+    ------------------------------------------------------------------ */
+    function applyOffset() {
+        const h = bar.offsetHeight;
+        document.documentElement.style.setProperty( '--clusterlane-bar-height', h + 'px' );
+    }
+    applyOffset();
+    window.addEventListener( 'resize', applyOffset );
+
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/clusterlane-tips.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/clusterlane-tips.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/clusterlane-tips.js	(revision 3751027)
@@ -0,0 +1,240 @@
+/* Clusterlane — "?" tooltips.
+ *
+ * Triggers: <span class="clusterlane-tip" data-clusterlane-tip="entropy" …> — rendered by
+ * clusterlane_tip() in PHP or window.clusterlaneTip( 'entropy' ) in JS. Definitions come
+ * from clusterlane_tip_definitions() via wp_localize_script (clusterlaneTipsData).
+ *
+ * One shared popover, appended to <body> and positioned from the trigger's
+ * bounding box, so it is never clipped by table/overflow containers and
+ * flips above/below and clamps to the viewport. Hover and keyboard focus
+ * show it, Escape hides it, tap toggles it on touch screens. Event
+ * delegation means triggers added later by JS work without setup.
+ */
+( function () {
+    'use strict';
+
+    var DEFS = ( typeof clusterlaneTipsData !== 'undefined' && clusterlaneTipsData.defs ) ? clusterlaneTipsData.defs : {};
+    var L    = ( typeof clusterlaneTipsData !== 'undefined' && clusterlaneTipsData.labels ) ? clusterlaneTipsData.labels : { good: 'Good:', fix: 'Improve:', what: 'What is %s?' };
+    var pop  = null;
+    var current = null;
+    var hideTimer = null;
+
+    function esc( s ) {
+        return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
+    }
+
+    /** Inline line glyph (same set as clusterlane_glyph() in PHP) — replaces emoji in JS-rendered UI. */
+    var GLYPHS = ( typeof clusterlaneTipsData !== 'undefined' && clusterlaneTipsData.glyphs ) ? clusterlaneTipsData.glyphs : {};
+    window.clusterlaneGlyph = function ( name ) {
+        var p = GLYPHS[ name ];
+        return p ? '<svg class="clusterlane-g clusterlane-g--' + name + '" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + p + '</svg>' : '';
+    };
+
+    /**
+     * Small article thumbnail (0.98.100) — the same everywhere an article is
+     * listed. url = featured image (thumbnail size) or '' → a letter tile tinted
+     * in the article's role colour. Returns '' when thumbnails are turned off
+     * (Settings → Content → Article thumbnails). Decorative: the title is
+     * always next to it, so alt="".
+     *   size: 'xs' (22px) | 'sm' (28px, default) | 'md' (36px)
+     */
+    var THUMBS_ON = ! ( typeof clusterlaneTipsData !== 'undefined' && String( clusterlaneTipsData.thumbs ) === '0' );
+    window.clusterlaneThumbsOn = function () { return THUMBS_ON; };
+    window.clusterlaneThumb = function ( url, title, role, size ) {
+        if ( ! THUMBS_ON ) return '';
+        var cls = 'clusterlane-thumb clusterlane-thumb--' + ( size || 'sm' );
+        if ( url ) {
+            return '<span class="' + cls + '"><img src="' + esc( url ) + '" alt="" loading="lazy" decoding="async"></span>';
+        }
+        var ch = String( title || '' ).replace( /&[a-z#0-9]+;/gi, '' ).trim().charAt( 0 ).toUpperCase() || '·';
+        return '<span class="' + cls + ' clusterlane-thumb--none' + ( role ? ' clusterlane-thumb--' + esc( role ) : '' ) + '" aria-hidden="true">' + esc( ch ) + '</span>';
+    };
+
+    /**
+     * Article panel next to a cluster graph (0.98.199) — Cluster Inspector's
+     * full-screen graph and the Dashboard's expanded graph share it.
+     *   el      the right-hand column
+     *   o.node  the clicked node; o.nodes / o.edges the graph's raw data
+     *   o.full(n)   the article's whole title (labels in the graph are cut)
+     *   o.short(n)  the title by the site's display rule, for the link lists
+     *   o.inspectorUrl, o.fromKey, o.fromLabel  → the Article Inspector button
+     *   o.onPick(id)  a row in a link list was clicked → select that node
+     */
+    var NP_ROLES = {
+        pillar:  { c: '#e76c2e', bg: '#fff0e6', t: 'Pillar' },
+        support: { c: '#d4a514', bg: '#fffbe6', t: 'Support' },
+        light:   { c: '#6f9a42', bg: '#f0fdf4', t: 'Light' },
+        orphan:  { c: '#2979a8', bg: '#eff6ff', t: 'Orphan' },
+        hub:     { c: '#64748b', bg: '#f1f5f9', t: 'Archive page' }
+    };
+    var NP_ORDER = { pillar: 0, support: 1, light: 2, orphan: 3, hub: 4 };
+    window.clusterlaneNodePanel = function ( el, o ) {
+        if ( ! el ) return;
+        var n = o.node, nodes = o.nodes || [], edges = o.edges || [];
+        if ( ! n ) { el.innerHTML = window.clusterlaneNodePanelEmpty(); return; }
+        var full  = o.full || function ( x ) { return String( x.label || '' ); };
+        var short = o.short || full;
+        var role  = NP_ROLES[ n.class ] ? n.class : 'light';
+        var R     = NP_ROLES[ role ];
+        var isHub = role === 'hub';
+        var byId  = {};
+        nodes.forEach( function ( x ) { byId[ x.id ] = x; } );
+
+        var outIds = [], inIds = [];
+        edges.forEach( function ( e ) {
+            if ( e.from === n.id && byId[ e.to ] && outIds.indexOf( e.to ) < 0 ) outIds.push( e.to );
+            if ( e.to === n.id && byId[ e.from ] && inIds.indexOf( e.from ) < 0 ) inIds.push( e.from );
+        } );
+        var sortIds = function ( ids ) {
+            return ids.map( function ( id ) { return byId[ id ]; } ).sort( function ( a, b ) {
+                var ra = NP_ORDER[ a.class ] != null ? NP_ORDER[ a.class ] : 2, rb = NP_ORDER[ b.class ] != null ? NP_ORDER[ b.class ] : 2;
+                return ra - rb || short( a ).localeCompare( short( b ), undefined, { numeric: true, sensitivity: 'base' } );
+            } );
+        };
+
+        var img = ( ! isHub && n.thumb && THUMBS_ON ) ? '<img class="clusterlane-np__img" src="' + esc( n.thumb ) + '" alt="" loading="lazy" decoding="async">' : '';
+        var nIn = inIds.length, nOut = outIds.length;
+        var inCls = nIn > 5 ? ' is-good' : nIn > 2 ? ' is-ok' : ' is-low';
+        var score = n.score != null && n.score !== '' ? parseInt( n.score, 10 ) : null;
+        var stats = '<div class="clusterlane-np__stat" title="Links from other articles in this cluster"><b class="' + inCls + '">' + nIn + '</b><span>Links in</span></div>'
+                  + '<div class="clusterlane-np__stat" title="Links to other articles in this cluster"><b>' + nOut + '</b><span>Links out</span></div>'
+                  + ( score !== null && ! isNaN( score ) ? '<div class="clusterlane-np__stat"><b class="' + ( score >= 80 ? 'is-good' : score >= 60 ? 'is-ok' : 'is-bad' ) + '">' + score + '%</b><span>Score</span></div>' : '' );
+
+        var actions = '';
+        if ( n.url ) {
+            actions += '<a class="clusterlane-np__btn clusterlane-np__btn--primary" href="' + esc( n.url ) + '" target="_blank" rel="noopener">'
+                     + window.clusterlaneGlyph( isHub ? 'globe' : 'pencil' ) + ( isHub ? 'View archive page' : 'Edit article' ) + '</a>';
+        }
+        if ( ! isHub && o.inspectorUrl ) {
+            actions += '<a class="clusterlane-np__btn" href="' + esc( o.inspectorUrl + '&post_id=' + encodeURIComponent( n.id )
+                     + ( o.fromKey ? '&from_cluster=' + encodeURIComponent( o.fromKey ) + '&from_cluster_label=' + encodeURIComponent( o.fromLabel || '' ) : '' ) ) + '">'
+                     + window.clusterlaneGlyph( 'search' ) + 'Article Inspector</a>';
+        }
+
+        var list = function ( heading, ids, other, empty ) {
+            var rows = sortIds( ids ).map( function ( x ) {
+                var xr   = NP_ROLES[ x.class ] ? x.class : 'light';
+                var both = other.indexOf( x.id ) >= 0;
+                var name = short( x ), whole = full( x );
+                return '<li class="clusterlane-np__row">'
+                    + '<button type="button" class="clusterlane-np__item" data-np-pick="' + esc( x.id ) + '" title="' + esc( whole + ' — ' + NP_ROLES[ xr ].t ) + '">'
+                    + '<span class="clusterlane-np__dot" style="background:' + NP_ROLES[ xr ].c + '"></span>'
+                    + '<span class="clusterlane-np__name">' + esc( name ) + '</span>'
+                    + ( both ? '<span class="clusterlane-np__both" title="Links both ways">⇄</span>' : '' )
+                    + '</button>'
+                    + ( x.url ? '<a class="clusterlane-np__open" href="' + esc( x.url ) + '" target="_blank" rel="noopener" title="' + ( xr === 'hub' ? 'View archive page' : 'Edit article' ) + '" aria-label="' + ( xr === 'hub' ? 'View archive page' : 'Edit article' ) + ': ' + esc( whole ) + '">' + window.clusterlaneGlyph( xr === 'hub' ? 'globe' : 'pencil' ) + '</a>' : '' )
+                    + '</li>';
+            } ).join( '' );
+            return '<section class="clusterlane-np__sec"><h4 class="clusterlane-np__h">' + heading + '<span class="clusterlane-np__n">' + ids.length + '</span></h4>'
+                 + ( rows ? '<ul class="clusterlane-np__list">' + rows + '</ul>' : '<p class="clusterlane-np__none">' + empty + '</p>' ) + '</section>';
+        };
+
+        el.innerHTML = '<div class="clusterlane-np">'
+            + img
+            + '<span class="clusterlane-np__role" style="color:' + R.c + ';background:' + R.bg + ';border-color:' + R.c + '">' + R.t + '</span>'
+            + '<h3 class="clusterlane-np__title">' + esc( full( n ) ) + '</h3>'
+            + '<div class="clusterlane-np__stats">' + stats + '</div>'
+            + ( actions ? '<div class="clusterlane-np__actions">' + actions + '</div>' : '' )
+            + list( 'Links to', outIds, inIds, 'No links to other articles in this cluster.' )
+            + list( 'Linked from', inIds, outIds, 'No article in this cluster links here.' )
+            + ( outIds.some( function ( id ) { return inIds.indexOf( id ) >= 0; } ) ? '<p class="clusterlane-np__key"><span class="clusterlane-np__both">⇄</span> links both ways · click a name to show it in the graph</p>' : '<p class="clusterlane-np__key">Click a name to show it in the graph</p>' )
+            + '</div>';
+        el.scrollTop = 0;
+
+        if ( ! el.dataset.npBound ) {
+            el.dataset.npBound = '1';
+            el.addEventListener( 'click', function ( ev ) {
+                var b = ev.target.closest( '[data-np-pick]' );
+                if ( ! b || ! el._npPick ) return;
+                var raw = b.getAttribute( 'data-np-pick' );
+                el._npPick( /^\d+$/.test( raw ) ? parseInt( raw, 10 ) : raw );
+            } );
+        }
+        el._npPick = o.onPick || null;
+    };
+    window.clusterlaneNodePanelEmpty = function () {
+        return '<p class="clusterlane-np__empty">' + window.clusterlaneGlyph( 'nodes' ) + 'Click an article in the graph to see its links.</p>';
+    };
+
+    /** Markup for a trigger — use in any JS-rendered template. */
+    window.clusterlaneTip = function ( key ) {
+        var d = DEFS[ key ];
+        if ( ! d ) return '';
+        return '<span class="clusterlane-tip" data-clusterlane-tip="' + esc( key ) + '" tabindex="0" role="button" aria-label="'
+             + esc( L.what.replace( '%s', d.label ) ) + '"><span class="clusterlane-tip-icon" aria-hidden="true">?</span></span>';
+    };
+
+    function ensurePop() {
+        if ( pop ) return pop;
+        pop = document.createElement( 'div' );
+        pop.className = 'clusterlane-tip-pop';
+        pop.id = 'clusterlane-tip-pop';
+        pop.setAttribute( 'role', 'tooltip' );
+        pop.addEventListener( 'mouseenter', function () { clearTimeout( hideTimer ); } );
+        pop.addEventListener( 'mouseleave', scheduleHide );
+        document.body.appendChild( pop );
+        return pop;
+    }
+
+    function show( trigger ) {
+        var d = DEFS[ trigger.getAttribute( 'data-clusterlane-tip' ) ];
+        if ( ! d ) return;
+        clearTimeout( hideTimer );
+        var p = ensurePop();
+        p.innerHTML = '<strong class="clusterlane-tip-pop-title">' + esc( d.label ) + '</strong>'
+            + '<span class="clusterlane-tip-pop-text">' + esc( d.text ) + '</span>'
+            + ( d.good ? '<span class="clusterlane-tip-pop-row"><b>' + esc( L.good ) + '</b> ' + esc( d.good ) + '</span>' : '' )
+            + ( d.fix  ? '<span class="clusterlane-tip-pop-row"><b>' + esc( L.fix )  + '</b> ' + esc( d.fix )  + '</span>' : '' );
+        p.classList.add( 'is-open' );
+        if ( current && current !== trigger ) current.removeAttribute( 'aria-describedby' );
+        current = trigger;
+        trigger.setAttribute( 'aria-describedby', 'clusterlane-tip-pop' );
+        position( trigger, p );
+    }
+
+    function position( trigger, p ) {
+        var r   = trigger.getBoundingClientRect();
+        var vw  = document.documentElement.clientWidth;
+        var vh  = window.innerHeight;
+        p.style.left = '0px'; p.style.top = '0px'; p.style.maxWidth = Math.min( 300, vw - 16 ) + 'px';
+        var pw = p.offsetWidth, ph = p.offsetHeight, gap = 8;
+        var below = r.bottom + gap + ph <= vh || r.top - gap - ph < 0;
+        var top   = below ? r.bottom + gap : r.top - gap - ph;
+        var left  = Math.max( 8, Math.min( r.left + r.width / 2 - pw / 2, vw - pw - 8 ) );
+        p.style.top  = ( top + window.scrollY ) + 'px';
+        p.style.left = ( left + window.scrollX ) + 'px';
+        p.classList.toggle( 'is-below', below );
+        p.style.setProperty( '--clusterlane-tip-arrow', ( r.left + r.width / 2 - left ) + 'px' );
+    }
+
+    function hide() {
+        if ( pop ) pop.classList.remove( 'is-open' );
+        if ( current ) current.removeAttribute( 'aria-describedby' );
+        current = null;
+    }
+    function scheduleHide() { clearTimeout( hideTimer ); hideTimer = setTimeout( hide, 120 ); }
+
+    function triggerFrom( e ) { return e.target && e.target.closest ? e.target.closest( '.clusterlane-tip[data-clusterlane-tip]' ) : null; }
+
+    document.addEventListener( 'mouseover', function ( e ) { var t = triggerFrom( e ); if ( t ) show( t ); } );
+    document.addEventListener( 'mouseout',  function ( e ) { var t = triggerFrom( e ); if ( t && ! t.contains( e.relatedTarget ) ) scheduleHide(); } );
+    document.addEventListener( 'focusin',   function ( e ) { var t = triggerFrom( e ); if ( t ) show( t ); } );
+    document.addEventListener( 'focusout',  function ( e ) { if ( triggerFrom( e ) ) scheduleHide(); } );
+    document.addEventListener( 'click', function ( e ) {
+        var t = triggerFrom( e );
+        if ( t ) {
+            // Inside <summary>/<a>/<th sortable>: the tip must not toggle or navigate.
+            e.preventDefault(); e.stopPropagation();
+            current === t && pop && pop.classList.contains( 'is-open' ) ? hide() : show( t );
+        } else if ( pop && ! pop.contains( e.target ) ) {
+            hide();
+        }
+    }, true );
+    document.addEventListener( 'keydown', function ( e ) {
+        if ( e.key === 'Escape' ) hide();
+        var t = triggerFrom( e );
+        if ( t && ( e.key === 'Enter' || e.key === ' ' ) ) { e.preventDefault(); show( t ); }
+    } );
+    window.addEventListener( 'scroll', function () { if ( current ) position( current, pop ); }, true );
+    window.addEventListener( 'resize', hide );
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/dashboard.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/dashboard.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/dashboard.js	(revision 3751027)
@@ -0,0 +1,2682 @@
+/* Content Cluster Intelligence — Dashboard JS v0.1 (vanilla JS) */
+var clusterlaneCloseModal = function() {};
+var clusterlanePulseTimer = null;
+// "?" tooltip markup (admin/js/clusterlane-tips.js); '' if the tips script is missing.
+function clusterlaneTipHtml( key ) { return typeof window.clusterlaneTip === 'function' ? window.clusterlaneTip( key ) : ''; }
+// manage_clusterlane? wp_localize_script turns values into strings → parseInt.
+function clusterlaneCanManage() {
+    return typeof clusterlaneData !== 'undefined' && parseInt( clusterlaneData.canManage, 10 ) === 1;
+}
+// One GET /clusters per page load, shared by search, Site map, graph widget
+// and Site Analysis. Resolves to the raw response ({ summary, clusters }).
+function clusterlaneDashClustersLoad() {
+    if ( ! window.clusterlaneDashClustersP ) {
+        window.clusterlaneDashClustersP = fetch( clusterlaneData.restUrl + 'clusters', { headers: { 'X-WP-Nonce': clusterlaneData.nonce } } )
+            .then( function( r ) { if ( ! r.ok ) throw new Error( 'HTTP ' + r.status ); return r.json(); } );
+    }
+    return window.clusterlaneDashClustersP;
+}
+(function () {
+    'use strict';
+
+    if (typeof clusterlaneData === 'undefined') return;
+
+    var allIds     = [];
+    var currentIdx = 0;
+    var running    = false;
+    var scanned    = 0;
+    var errors     = 0;
+
+    /* ------------------------------------------------------------------
+       Helpers
+    ------------------------------------------------------------------ */
+    function qs(sel) { return document.querySelector(sel); }
+
+    function setStatus(msg, cls) {
+        var el = qs('#clusterlane-scan-status');
+        if (!el) return;
+        el.textContent = msg;
+        el.className   = 'clusterlane-scan-status' + (cls ? ' ' + cls : '');
+    }
+
+    function updateProgress(done, total) {
+        var pct = Math.round((done / total) * 100);
+        var bar = qs('#clusterlane-progress-bar');
+        var lbl = qs('#clusterlane-progress-label');
+        if (bar) bar.style.width = pct + '%';
+        if (lbl) lbl.textContent = done + ' / ' + total + ' posts';
+    }
+
+    function show(id)  { var el = qs('#' + id); if (el) el.style.display = ''; }
+    function hide(id)  { var el = qs('#' + id); if (el) el.style.display = 'none'; }
+    function enable(id)  { var el = qs('#' + id); if (el) el.disabled = false; }
+    function disable(id) { var el = qs('#' + id); if (el) el.disabled = true; }
+    function setText(id, t) { var el = qs('#' + id); if (el) el.textContent = t; }
+
+    function apiFetch(path, method, body) {
+        var opts = {
+            method:      method || 'GET',
+            headers: {
+                'X-WP-Nonce':   clusterlaneData.nonce,
+                'Content-Type': 'application/json',
+            },
+            credentials: 'same-origin',
+        };
+        if (body) opts.body = JSON.stringify(body);
+
+        return fetch(clusterlaneData.restUrl + path, opts).then(function (r) {
+            if (!r.ok) throw new Error('HTTP ' + r.status);
+            return r.json();
+        });
+    }
+
+    /* ------------------------------------------------------------------
+       Sync clusters
+    ------------------------------------------------------------------ */
+    var syncBtn = qs('#clusterlane-sync-clusters');
+    if (syncBtn) {
+        syncBtn.addEventListener('click', function () {
+            syncBtn.disabled    = true;
+            syncBtn.textContent = 'Syncing…';
+            setStatus('Syncing clusters from categories…');
+
+            apiFetch('clusters/sync', 'POST')
+                .then(function (res) {
+                    setStatus('✓ Clusters synced. ' + (res.created || 0) + ' new clusters created.', 'success');
+                    var ls = document.querySelector('.clusterlane-hero-lastscan');
+                    if ( ls ) ls.textContent = 'Synced just now';
+                    setTimeout(function () { location.reload(); }, 1000);
+                })
+                .catch(function () {
+                    setStatus('Error syncing clusters.', 'error');
+                    syncBtn.disabled    = false;
+                    syncBtn.textContent = '⟳ Sync Clusters';
+                });
+        });
+    }
+
+    /* ------------------------------------------------------------------
+       Compute metrics
+    ------------------------------------------------------------------ */
+    var computeBtn = qs('#clusterlane-compute-clusters');
+    var computeBtnHtml = computeBtn ? computeBtn.innerHTML : '';
+    if (computeBtn) {
+        computeBtn.addEventListener('click', function () {
+            computeBtn.disabled    = true;
+            computeBtn.textContent = 'Computing…';
+            setStatus('Computing cluster metrics…');
+
+            apiFetch('clusters/compute', 'POST')
+                .then(function (res) {
+                    setStatus('✓ Metrics computed for ' + (res.computed || 0) + ' clusters.', 'success');
+                    var ls = document.querySelector('.clusterlane-hero-lastscan');
+                    if ( ls ) ls.textContent = 'Computed just now';
+                    setTimeout(function () { location.reload(); }, 1000);
+                })
+                .catch(function () {
+                    setStatus('Error computing metrics.', 'error');
+                    computeBtn.disabled    = false;
+                    computeBtn.innerHTML   = computeBtnHtml;
+                });
+        });
+    }
+
+    var BATCH_SIZE = parseInt(clusterlaneData.batchSize, 10) || 25;
+
+    /* ------------------------------------------------------------------
+       Shared scan runner (used by Full Site Scan and Onboarding)
+       Processes ids in batches. If a batch fails (HTTP error or 60s
+       timeout) it is split in half and retried, down to single posts,
+       so a slow/failing batch never silently drops its posts.
+    ------------------------------------------------------------------ */
+    function clusterlaneRunScan(ids, batchSize, hooks) {
+        var queue   = [];
+        var total   = ids.length;
+        var done    = 0;      // posts accounted for (scanned or failed)
+        var scanned = 0;
+        var failed  = [];
+        var stopped = false;
+        var pages   = { ok: 0, failed: 0, example: null }; // published pages read (0.98.177)
+
+        for (var i = 0; i < ids.length; i += batchSize) queue.push(ids.slice(i, i + batchSize));
+
+        function post(path, body) {
+            return fetch(clusterlaneData.restUrl + path, {
+                method: 'POST',
+                headers: { 'X-WP-Nonce': clusterlaneData.nonce, 'Content-Type': 'application/json' },
+                credentials: 'same-origin',
+                body: JSON.stringify(body),
+            }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
+        }
+
+        function next() {
+            if (stopped) return;
+            if (!queue.length) { hooks.onDone && hooks.onDone({ scanned: scanned, failed: failed, total: total, pages: pages }); return; }
+
+            var batch = queue.shift();
+            hooks.onProgress && hooks.onProgress({ done: done, total: total, batch: batch, scanned: scanned });
+
+            var settled = false;
+            var timer = setTimeout(function () { if (!settled) { settled = true; fail(); } }, 60000);
+
+            function fail() {
+                if (batch.length > 1) {
+                    var mid = Math.ceil(batch.length / 2);
+                    queue.unshift(batch.slice(mid));
+                    queue.unshift(batch.slice(0, mid));
+                } else {
+                    failed.push(batch[0]);
+                    done += 1;
+                }
+                setTimeout(next, 150);
+            }
+
+            // 'later' (0.98.178): published pages are read afterwards, in the background.
+            post('scan/batch', { ids: batch, pages: hooks.pages || 'later' })
+                .then(function (res) {
+                    if (settled) return; settled = true; clearTimeout(timer);
+                    var n = res.scanned || 0;
+                    // Batches that read published pages hand back what didn't fit in ~20 s.
+                    var rest = Array.isArray(res.remaining) ? res.remaining.map(Number).filter(Boolean) : [];
+                    scanned += n; done += batch.length - rest.length;
+                    if (rest.length) queue.unshift(rest);
+                    if (res.pages) { pages.ok += +res.pages.ok || 0; pages.failed += +res.pages.failed || 0; if (!pages.example && res.pages.example) pages.example = res.pages.example; }
+                    setTimeout(next, 50);
+                })
+                .catch(function () {
+                    if (settled) return; settled = true; clearTimeout(timer);
+                    fail();
+                });
+        }
+
+        next();
+        return { stop: function () { stopped = true; } };
+    }
+
+    /* ------------------------------------------------------------------
+       Full site scan — batch mode
+    ------------------------------------------------------------------ */
+    var startBtn = qs('#clusterlane-start-scan');
+    // The batch runner, for Onboarding (it lives in another scope further
+    // down). The old window.clusterlaneRunScan clicked the hero Scan button, which
+    // was removed in 0.98.089 — so onboarding's scan silently did nothing.
+    window.clusterlaneScanRunner = clusterlaneRunScan;
+    var stopBtn  = qs('#clusterlane-stop-scan');
+
+    if (startBtn) {
+        startBtn.addEventListener('click', function () {
+            if (running) return;
+
+            running    = true;
+            scanned    = 0;
+            errors     = 0;
+            currentIdx = 0;
+
+            disable('clusterlane-start-scan');
+            show('clusterlane-stop-scan');
+            show('clusterlane-scan-progress');
+            setStatus('Loading post list…');
+
+            apiFetch('scan/posts')
+                .then(function (res) {
+                    if (!res.ids || !res.ids.length) {
+                        setStatus('No posts found.', 'error');
+                        finish();
+                        return;
+                    }
+                    allIds = res.ids;
+                    scanRun = clusterlaneRunScan(allIds, BATCH_SIZE, {
+                        onProgress: function (p) {
+                            updateProgress(p.done, p.total);
+                            setStatus('Scanning ' + (p.done + 1) + '–' + Math.min(p.done + p.batch.length, p.total) + ' / ' + p.total + '…');
+                        },
+                        onDone: function (r) {
+                            scanned = r.scanned; errors = r.failed.length;
+                            finish();
+                            var errNote = errors > 0 ? ', ' + errors + ' could not be scanned (IDs: ' + r.failed.slice(0, 10).join(', ') + (errors > 10 ? '…' : '') + ')' : '';
+                            setStatus('✓ Scan complete — ' + scanned + ' posts scanned' + errNote + '. Computing metrics…', errors ? 'warn' : 'success');
+                            apiFetch('clusters/compute', 'POST')
+                                .then(function (res) {
+                                    setStatus('✓ Done — ' + scanned + ' posts scanned' + errNote + '. Metrics computed for ' + (res.computed || 0) + ' clusters.', errors ? 'warn' : 'success');
+                                    if (typeof window.clusterlaneReadPages === 'function') window.clusterlaneReadPages(); // published pages queued by the scan (0.98.178)
+                                })
+                                .catch(function () {
+                                    setStatus('✓ Scan done, but updating the cluster figures failed — click Scan to try again.', 'warn');
+                                })
+                                .finally(function () {
+                                    setTimeout(function () { location.reload(); }, errors ? 4000 : 2000);
+                                });
+                        }
+                    });
+                })
+                .catch(function () {
+                    setStatus('Could not load post list.', 'error');
+                    finish();
+                });
+        });
+    }
+
+    var scanRun = null;
+    if (stopBtn) {
+        stopBtn.addEventListener('click', function () {
+            running = false;
+            if (scanRun) scanRun.stop();
+            setStatus('Scan stopped.');
+            finish();
+        });
+    }
+
+    function finish() {
+        running = false;
+        enable('clusterlane-start-scan');
+        hide('clusterlane-stop-scan');
+        // Update "Last scan" live so the user sees confirmation before reload.
+        var ls = document.querySelector('.clusterlane-hero-lastscan');
+        if ( ls ) ls.textContent = 'Last scan: just now';
+    }
+
+})();
+
+
+    /* ------------------------------------------------------------------
+       Custom cluster creator
+    ------------------------------------------------------------------ */
+    (function () {
+        function qs(sel) { return document.querySelector(sel); }
+        function apiFetch(p,m,b){var o={method:m||"POST",headers:{"X-WP-Nonce":clusterlaneData.nonce,"Content-Type":"application/json"},credentials:"same-origin"};if(b)o.body=JSON.stringify(b);return fetch(clusterlaneData.restUrl+p,o).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();});}
+        var selectedPosts = {}; // { post_id: post_title }
+        var searchTimer   = null;
+
+        var labelInput   = qs('#clusterlane-custom-label');
+        var searchInput  = qs('#clusterlane-custom-search');
+        var resultsBox   = qs('#clusterlane-custom-results');
+        var membersList  = qs('#clusterlane-custom-members-list');
+        var emptyNote    = qs('#clusterlane-custom-empty');
+        var saveBtn      = qs('#clusterlane-custom-save');
+        var statusEl     = qs('#clusterlane-custom-status');
+
+        if (!labelInput) return; // not on dashboard
+
+        function updateSaveBtn() {
+            var hasLabel   = labelInput.value.trim().length > 0;
+            var hasMembers = Object.keys(selectedPosts).length > 0;
+            saveBtn.disabled = !(hasLabel && hasMembers);
+        }
+
+        function renderMembers() {
+            membersList.innerHTML = '';
+            var ids = Object.keys(selectedPosts);
+            emptyNote.style.display = ids.length === 0 ? '' : 'none';
+
+            ids.forEach(function (id) {
+                var pill = document.createElement('span');
+                pill.style.cssText = 'display:inline-flex;align-items:center;gap:4px;background:#e8f0e9;color:#1a5c2a;border-radius:12px;padding:3px 10px;font-size:.8rem;';
+                pill.innerHTML = '<span>' + escHtml(selectedPosts[id]) + '</span>'
+                    + '<button data-id="' + id + '" style="background:none;border:none;cursor:pointer;color:#888;font-size:1rem;line-height:1;padding:0 2px;">×</button>';
+
+                pill.querySelector('button').addEventListener('click', function () {
+                    delete selectedPosts[this.dataset.id];
+                    renderMembers();
+                    updateSaveBtn();
+                });
+
+                membersList.appendChild(pill);
+            });
+        }
+
+        // Search
+        searchInput && searchInput.addEventListener('input', function () {
+            clearTimeout(searchTimer);
+            var q = this.value.trim();
+            if (q.length < 2) { resultsBox.style.display = 'none'; return; }
+
+            searchTimer = setTimeout(function () {
+                apiFetch('posts/search', 'POST', { q: q, limit: 10 })
+                    .then(function (res) {
+                        if (!res.posts || !res.posts.length) {
+                            resultsBox.innerHTML = '<div style="padding:8px 12px;color:#888;font-size:.85rem;">No results</div>';
+                        } else {
+                            var html = '';
+                            var shownSeparator = false;
+                            res.posts.forEach(function(p) {
+                                if (!shownSeparator && p.match_type === 'content') {
+                                    html += '<div style="padding:4px 12px;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;background:#f8fafc;border-bottom:1px solid #f1f5f9;">Also mentions this</div>';
+                                    shownSeparator = true;
+                                }
+                                html += '<div class="clusterlane-search-result" data-id="' + p.id + '" data-title="' + escAttr(p.title) + '" '
+                                    + 'style="padding:8px 12px;cursor:pointer;font-size:.85rem;border-bottom:1px solid #f0f0f0;">'
+                                    + escHtml(p.title) + '</div>';
+                            });
+                            resultsBox.innerHTML = html;
+                        }
+                        resultsBox.style.display = '';
+
+                        resultsBox.querySelectorAll('.clusterlane-search-result').forEach(function (el) {
+                            el.addEventListener('mouseenter', function () { this.style.background = '#f0f7f1'; });
+                            el.addEventListener('mouseleave', function () { this.style.background = ''; });
+                            el.addEventListener('click', function () {
+                                var id    = this.dataset.id;
+                                var title = this.dataset.title;
+                                selectedPosts[id] = title;
+                                renderMembers();
+                                updateSaveBtn();
+                                resultsBox.style.display = 'none';
+                                searchInput.value = '';
+                            });
+                        });
+                    })
+                    .catch(function () { resultsBox.style.display = 'none'; });
+            }, 300);
+        });
+
+        // Hide results on outside click
+        document.addEventListener('click', function (e) {
+            if (resultsBox && !resultsBox.contains(e.target) && e.target !== searchInput) {
+                resultsBox.style.display = 'none';
+            }
+        });
+
+        labelInput && labelInput.addEventListener('input', updateSaveBtn);
+
+        // Save
+        saveBtn && saveBtn.addEventListener('click', function () {
+            var label    = labelInput.value.trim();
+            var post_ids = Object.keys(selectedPosts).map(Number);
+
+            if (!label || !post_ids.length) return;
+
+            saveBtn.disabled    = true;
+            saveBtn.textContent = 'Creating…';
+            statusEl.textContent = '';
+
+            apiFetch('clusters/custom', 'POST', { label: label, post_ids: post_ids })
+                .then(function (res) {
+                    statusEl.textContent  = '✓ Cluster "' + label + '" created with ' + res.members + ' articles.';
+                    statusEl.style.color  = '#1a7f37';
+                    labelInput.value      = '';
+                    selectedPosts         = {};
+                    renderMembers();
+                    saveBtn.textContent   = '+ Create Cluster';
+                    updateSaveBtn();
+                    setTimeout(function () { location.reload(); }, 1200);
+                })
+                .catch(function () {
+                    statusEl.textContent = 'Error creating cluster.';
+                    statusEl.style.color = '#b32d2e';
+                    saveBtn.disabled     = false;
+                    saveBtn.textContent  = '+ Create Cluster';
+                });
+        });
+
+        function escHtml(s) {
+            return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+        }
+        function escAttr(s) {
+            return String(s).replace(/"/g,'&quot;');
+        }
+    })();
+
+
+    /* ------------------------------------------------------------------
+       Cluster edit modal
+    ------------------------------------------------------------------ */
+    (function () {
+        function qs(sel) { return document.querySelector(sel); }
+        function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+        function escAttr(s) { return String(s).replace(/"/g,'&quot;'); }
+
+        function apiFetch(path, method, body) {
+            var opts = {
+                method: method || 'POST',
+                headers: { 'X-WP-Nonce': clusterlaneData.nonce, 'Content-Type': 'application/json' },
+                credentials: 'same-origin'
+            };
+            if (body) opts.body = JSON.stringify(body);
+            return fetch(clusterlaneData.restUrl + path, opts).then(function(r) {
+                if (!r.ok) throw new Error('HTTP ' + r.status);
+                return r.json();
+            });
+        }
+
+        var modal        = qs('#clusterlane-edit-modal');
+        var modalTitle   = qs('#clusterlane-modal-title');
+        var modalType    = qs('#clusterlane-modal-stats');
+        var modalRename  = qs('#clusterlane-modal-rename');
+        var labelInput   = qs('#clusterlane-modal-label-input');
+        var renameBtn    = qs('#clusterlane-modal-rename-btn');
+        var searchInput  = qs('#clusterlane-modal-search');
+        var searchRes    = qs('#clusterlane-modal-search-results');
+        var membersEl    = qs('#clusterlane-modal-members');
+        var statusEl     = qs('#clusterlane-modal-status');
+        var closeBtn     = qs('#clusterlane-modal-close');
+
+        if (!modal) return;
+
+        var currentKey  = '';
+        var currentType = '';
+        var searchTimer = null;
+
+        function setStatus(msg, color) {
+            if (statusEl) { statusEl.textContent = msg; statusEl.style.color = color || '#444'; }
+        }
+
+        function openModal(key, label, type, stats) {
+            currentKey  = key;
+            currentType = type;
+
+            modalTitle.textContent = label;
+            renderModalStats( type, stats || {} );
+            resetToMembersTab();
+
+            // Show rename only for custom clusters.
+            modalRename.style.display = type === 'custom' ? '' : 'none';
+            if (labelInput) labelInput.value = label;
+
+            modal.style.display = 'flex';
+
+            // Simulate a click on the Graph tab — this reuses the exact
+            // same code path that works when the user clicks manually,
+            // including panel show/hide and loadGraph timing.
+            requestAnimationFrame(function() {
+                var graphTab = qs('#clusterlane-modal-graph-tab');
+                if (graphTab) graphTab.click();
+            });
+        }
+
+        // Always land on the Graph tab, regardless of which tab was
+        // showing the last time the modal was open. The Graph tab and its
+        // Expand button live in a separate module (only built once, lazily)
+        // and only reload their data on an explicit tab click — so without
+        // this reset, reopening the modal for a different cluster while
+        // still "on" the Graph tab would keep showing the previous
+        // cluster's graph with nothing to trigger a refresh.
+        function resetToMembersTab() {
+            var membersPanel = document.getElementById('clusterlane-modal-members-panel');
+            var graphPanel   = document.getElementById('clusterlane-modal-graph-panel');
+            var gapPanel     = document.getElementById('clusterlane-modal-gap-panel');
+            if (membersPanel) membersPanel.style.display = 'none';
+            if (graphPanel)   graphPanel.style.display = '';
+            if (gapPanel)     gapPanel.style.display = 'none';
+
+            // Defensive backstop: expanding the graph hides these while
+            // fullscreen, and closeModal() is responsible for collapsing
+            // (and re-showing them) before the modal hides — but guarantee
+            // it here too, so a modal open is never at the mercy of that
+            // other code path having run cleanly.
+            var headerEl = document.getElementById('clusterlane-modal-header');
+            var tabsEl   = document.getElementById('clusterlane-modal-tabs');
+            if (headerEl) headerEl.style.display = '';
+            if (tabsEl)   tabsEl.style.display   = '';
+
+            if (tabsEl) {
+                tabsEl.querySelectorAll('.clusterlane-tab').forEach(function(b) {
+                    var isGraph = b.dataset.tab === 'graph';
+                    b.style.borderBottomColor = isGraph ? '#2271b1' : 'transparent';
+                    b.style.color             = isGraph ? '#2271b1' : '#555';
+                    b.style.fontWeight        = isGraph ? '600' : '400';
+                });
+            }
+
+            var expandBtn = document.getElementById('clusterlane-expand-btn');
+            if (expandBtn) expandBtn.style.display = 'none';
+        }
+
+        var TYPE_LABELS = { custom: '' + clusterlaneGlyph( 'pencil' ) + 'Custom', product_cat: '' + clusterlaneGlyph( 'bag' ) + 'Product category', post_tag: '' + clusterlaneGlyph( 'tag' ) + 'Tag', category: '' + clusterlaneGlyph( 'folder' ) + 'Category' };
+
+        // Renders the stat cluster to the right of the modal title: type,
+        // member count, health %, and archetype (when the cluster has one).
+        function renderModalStats( type, stats ) {
+            if ( !modalType ) return;
+
+            var pills = [];
+            pills.push( '<span class="clusterlane-modal-stat clusterlane-modal-stat--type">' + ( TYPE_LABELS[type] || ( typeof clusterlaneType === 'function' ? clusterlaneGlyph( clusterlaneType( type ).glyph ) + escHtml( clusterlaneType( type ).label ) : escHtml( type ) ) ) + '</span>' );
+
+            if ( stats.total !== undefined ) {
+                pills.push( '<span class="clusterlane-modal-stat">' + escHtml( stats.total ) + ' articles</span>' );
+            }
+            if ( stats.health !== undefined && stats.health !== '' ) {
+                var hClass = stats.healthClass ? ' clusterlane-' + stats.healthClass : '';
+                pills.push( '<span class="clusterlane-modal-stat">Health' + clusterlaneTipHtml( 'health' ) + ' <strong class="' + hClass + '">' + escHtml( stats.health ) + '%</strong></span>' );
+            }
+            if ( stats.archetype ) {
+                var slug = String( stats.archetype ).toLowerCase().replace( /\s+/g, '-' );
+                pills.push( '<span class="clusterlane-archetype-pill clusterlane-archetype-' + slug + '">' + escHtml( stats.archetype ) + '</span>' + clusterlaneTipHtml( 'archetype' ) );
+            }
+
+            // Entropy/Gini/Density are already computed and stored per
+            // cluster (same values shown in the All Clusters table), so
+            // they're available immediately — no need to wait for the
+            // Graph tab's REST call the way Links (edge count) does.
+            var structural = [
+                [ 'Entropy', stats.entropy, 'entropy' ],
+                [ 'Gini',    stats.gini,    'gini'    ],
+                [ 'Density', stats.density, 'density' ],
+            ].filter( function( pair ) { return pair[1] !== undefined && pair[1] !== ''; } );
+
+            structural.forEach( function( pair, i ) {
+                var cls = 'clusterlane-modal-stat' + ( i === 0 ? ' clusterlane-modal-stat--graph-first' : '' );
+                pills.push( '<span class="' + cls + '">' + pair[0] + clusterlaneTipHtml( pair[2] ) + ': <strong>' + escHtml( pair[1] ) + '</strong></span>' );
+            } );
+
+            modalType.innerHTML = pills.join('');
+        }
+
+        function closeModal() {
+            if (clusterlanePulseTimer) { clearInterval(clusterlanePulseTimer); clusterlanePulseTimer = null; }
+            // If the graph was left expanded to fullscreen, collapse it
+            // back to normal first — otherwise #clusterlane-modal-inner stays
+            // stuck at fullscreen size with the header/tabs hidden the
+            // next time this modal opens (for this cluster or another).
+            if (window.clusterlaneCollapseGraphIfExpanded) window.clusterlaneCollapseGraphIfExpanded();
+            if (window.clusterlaneResetGraphOnClose) window.clusterlaneResetGraphOnClose();
+            modal.style.display = 'none';
+            currentKey = '';
+            if (searchInput) searchInput.value = '';
+            if (searchRes) searchRes.style.display = 'none';
+            setStatus('');
+        }
+        clusterlaneCloseModal = closeModal;
+
+        closeBtn && closeBtn.addEventListener('click', closeModal);
+        modal.addEventListener('click', function(e) {
+            if (e.target === modal) closeModal();
+        });
+        document.addEventListener("keydown", function(e) {
+            if (e.key === "Escape") closeModal();
+        });
+
+        // Load cluster members via REST.
+        function loadMembers() {
+            if (!membersEl) return;
+            membersEl.innerHTML = '<div style="padding:8px;color:#888;font-size:.85rem;">Loading…</div>';
+
+            apiFetch('clusters/members/' + currentKey)
+                .then(function(res) { renderMembers(res.members || []); })
+                .catch(function() { membersEl.innerHTML = '<div style="padding:8px;color:#b32d2e;">Could not load members.</div>'; });
+        }
+
+        function renderMembers(members) {
+            if (!members.length) {
+                membersEl.innerHTML = '<div style="padding:8px;color:#888;font-size:.85rem;">No members yet.</div>';
+                return;
+            }
+            membersEl.innerHTML = members.map(function(m) {
+                return '<div class="clusterlane-modal-member-row">'
+                    + '<span class="clusterlane-modal-member-title">' + escHtml(m.title) + '</span>'
+                    + '<button class="clusterlane-row-btn clusterlane-row-btn--delete clusterlane-remove-member" data-id="' + m.id + '" '
+                    + 'title="Remove from cluster">×</button>'
+                    + '</div>';
+            }).join('');
+
+            membersEl.querySelectorAll('.clusterlane-remove-member').forEach(function(btn) {
+                btn.addEventListener('click', function() {
+                    var postId = parseInt(this.dataset.id);
+                    var row    = this.closest('.clusterlane-modal-member-row');
+                    row.style.opacity = '.4';
+
+                    apiFetch('clusters/custom/' + currentKey, 'POST', { remove_ids: [postId] })
+                        .then(function() { row.remove(); setStatus('Removed.', '#1a7f37'); })
+                        .catch(function() { row.style.opacity = '1'; setStatus('Error removing.', '#b32d2e'); });
+                });
+            });
+        }
+
+        // Search for articles to add.
+        searchInput && searchInput.addEventListener('input', function() {
+            clearTimeout(searchTimer);
+            var q = this.value.trim();
+            if (q.length < 2) { searchRes.style.display = 'none'; return; }
+
+            searchTimer = setTimeout(function() {
+                var xhr = new XMLHttpRequest();
+                xhr.open('POST', clusterlaneData.restUrl + 'posts/search');
+                xhr.setRequestHeader('X-WP-Nonce', clusterlaneData.nonce);
+                xhr.setRequestHeader('Content-Type', 'application/json');
+                xhr.onload = function() {
+                    try {
+                        var res = JSON.parse(xhr.responseText);
+                        if (!res.posts || !res.posts.length) {
+                            searchRes.innerHTML = '<div style="padding:8px;color:#888;font-size:.85rem;">No results</div>';
+                        } else {
+                            searchRes.innerHTML = res.posts.map(function(p) {
+                                return '<div class="clusterlane-modal-result" data-id="' + p.id + '" data-title="' + escAttr(p.title) + '" '
+                                    + 'style="padding:7px 10px;cursor:pointer;font-size:.85rem;border-bottom:1px solid #f5f5f5;">'
+                                    + escHtml(p.title) + '</div>';
+                            }).join('');
+                        }
+                        searchRes.style.display = '';
+                        searchRes.querySelectorAll('.clusterlane-modal-result').forEach(function(el) {
+                            el.addEventListener('mouseenter', function() { this.style.background = '#f0f7f1'; });
+                            el.addEventListener('mouseleave', function() { this.style.background = ''; });
+                            el.addEventListener('click', function() {
+                                var postId = parseInt(this.dataset.id);
+                                var title  = this.dataset.title;
+                                searchRes.style.display = 'none';
+                                searchInput.value = '';
+                                setStatus('Adding…', '#888');
+
+                                apiFetch('clusters/custom/' + currentKey, 'POST', { add_ids: [postId] })
+                                    .then(function() {
+                                        setStatus('Added: ' + title, '#1a7f37');
+                                        loadMembers();
+                                    })
+                                    .catch(function() { setStatus('Error adding.', '#b32d2e'); });
+                            });
+                        });
+                    } catch(e) { searchRes.style.display = 'none'; }
+                };
+                xhr.onerror = function() { searchRes.style.display = 'none'; };
+                xhr.send(JSON.stringify({ q: q, limit: 10 }));
+            }, 300);
+        });
+
+        // Rename (custom only).
+        renameBtn && renameBtn.addEventListener('click', function() {
+            var newLabel = labelInput.value.trim();
+            if (!newLabel) return;
+            apiFetch('clusters/custom/' + currentKey, 'POST', { label: newLabel })
+                .then(function() {
+                    setStatus('Renamed to: ' + newLabel, '#1a7f37');
+                    modalTitle.textContent = newLabel;
+                    setTimeout(function() { location.reload(); }, 800);
+                })
+                .catch(function() { setStatus('Error renaming.', '#b32d2e'); });
+        });
+
+        // Open modal on edit button click.
+        document.addEventListener('click', function(e) {
+            var btn = e.target.closest('.clusterlane-edit-cluster');
+            if (!btn) return;
+            openModal(btn.dataset.key, btn.dataset.label, btn.dataset.type, btn.dataset);
+        });
+
+        // Delete cluster.
+        document.addEventListener('click', function(e) {
+            var btn = e.target.closest('.clusterlane-delete-cluster');
+            if (!btn) return;
+            if (!confirm('Delete cluster "' + btn.dataset.label + '"? This cannot be undone.')) return;
+            btn.disabled = true;
+
+            apiFetch('clusters/custom/' + btn.dataset.key, 'DELETE')
+                .then(function() {
+                    btn.closest('tr').remove();
+                    setStatus('Cluster deleted.', '#1a7f37');
+                })
+                .catch(function() {
+                    btn.disabled = false;
+                    alert('Error deleting cluster.');
+                });
+        });
+
+        // Deactivate (hide) category/tag cluster.
+        document.addEventListener('click', function(e) {
+            var btn = e.target.closest('.clusterlane-deactivate-cluster');
+            if (!btn) return;
+            if (!confirm('Hide "' + btn.dataset.label + '" from dashboard? You can re-enable it in Settings.')) return;
+            btn.disabled = true;
+
+            apiFetch('clusters/deactivate/' + btn.dataset.key, 'POST')
+                .then(function() { btn.closest('tr').style.opacity = '.3'; })
+                .catch(function() { btn.disabled = false; });
+        });
+
+    })();
+
+    /* ------------------------------------------------------------------
+       Cluster graph (vis.js) — loaded lazily when modal opens
+    ------------------------------------------------------------------ */
+    (function () {
+        function qs(sel) { return document.querySelector(sel); }
+
+        var graphContainer = null;
+        var visNetwork     = null;
+        var currentGraphKey = '';
+        var lastGraphData  = null; // cached last-fetched graph payload, used to
+                                    // rebuild the canvas cleanly on expand/collapse
+                                    // instead of stretching the existing one (see below).
+        var graphResizeObserver = null; // watches the graph container's REAL size,
+                                         // rather than guessing a timeout after layout
+                                         // changes like Expand/Collapse.
+
+        var currentGapKey = ''; // cluster the last-loaded Gap result belongs to
+        var lastGapData    = null; // cached {analysis, suggestions, ...} payload
+
+        // Inject graph tab into modal after it's been added to DOM.
+        function ensureGraphTab() {
+            if (qs('#clusterlane-modal-graph-tab')) return; // Already added.
+
+            var header = qs('#clusterlane-modal-header');
+            if (!header) return;
+
+            // Add tabs below the title/stats header row.
+            var tabs = document.createElement('div');
+            tabs.id = 'clusterlane-modal-tabs';
+            tabs.style.cssText = 'display:flex;gap:0;margin-bottom:16px;border-bottom:2px solid #f0f0f0;';
+            tabs.innerHTML = '<button class="clusterlane-tab" data-tab="graph" id="clusterlane-modal-graph-tab" style="background:none;border:none;border-bottom:2px solid #2271b1;margin-bottom:-2px;padding:6px 14px;cursor:pointer;font-weight:600;color:#2271b1;">' + clusterlaneGlyph( 'chart' ) + 'Graph</button>'
+                + '<button class="clusterlane-tab" data-tab="gap" style="background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-2px;padding:6px 14px;cursor:pointer;color:#555;">' + clusterlaneGlyph( 'sparkle' ) + 'Gap</button>'
+                + '<button class="clusterlane-tab" data-tab="members" style="background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-2px;padding:6px 14px;cursor:pointer;color:#555;">Members</button>';
+            header.insertAdjacentElement('afterend', tabs);
+
+            // Expand-knapp — lives as a static overlay in the top-right
+            // corner of the graph canvas (see PHP markup); grabbed here
+            // rather than built dynamically. Nesting it inside
+            // #clusterlane-modal-graph-panel also means it now travels correctly
+            // with the panel during the expand/collapse reparenting below,
+            // instead of being destroyed by inner.innerHTML = '' as before.
+            var expandBtn = qs('#clusterlane-expand-btn');
+            if (!expandBtn) return;
+
+            // Expand/collapse only ever MOVES existing elements — it never
+            // destroys and rebuilds #clusterlane-modal-inner's content. An earlier
+            // version did `inner.innerHTML = ''` and reconstructed a
+            // temporary topbar+3-column layout from scratch, which
+            // permanently destroyed #clusterlane-modal-header, #clusterlane-modal-tabs,
+            // #clusterlane-modal-close, #clusterlane-modal-members-panel, and
+            // #clusterlane-modal-status the first time anyone expanded the graph —
+            // collapsing (or closing, or reopening for another cluster)
+            // never brought them back, since nothing recreated them. This
+            // version keeps every original element alive for the life of
+            // the page; expand/collapse just relocates the graph panel
+            // into (and back out of) a 3-column layout and toggles a
+            // couple of display values.
+            function expandToFullscreen() {
+                var inner = qs('#clusterlane-modal-inner');
+                inner.style.cssText = 'background:#fff;border-radius:8px;padding:28px;width:98vw;max-width:98vw;height:96vh;overflow:hidden;position:relative;display:flex;flex-direction:column;';
+                inner.dataset.expanded = '1';
+                expandBtn.innerHTML = clusterlaneGlyph( 'collapse' ) + 'Collapse';
+
+                var layout = qs('#clusterlane-expand-layout');
+                if (!layout) {
+                    layout = document.createElement('div');
+                    layout.id = 'clusterlane-expand-layout';
+                    layout.style.cssText = 'display:flex;flex:1;overflow:hidden;margin-top:12px;';
+
+                    var leftCol = document.createElement('div');
+                    leftCol.id = 'clusterlane-expand-left';
+                    leftCol.style.cssText = 'width:240px;flex-shrink:0;padding:16px;border-right:1px solid #eee;overflow-y:auto;background:#fafafa;';
+
+                    var mainCol = document.createElement('div');
+                    mainCol.id = 'clusterlane-expand-main';
+                    mainCol.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;padding:0 16px;';
+
+                    var rightCol = document.createElement('div');
+                    rightCol.id = 'clusterlane-expand-right';
+                    rightCol.style.cssText = 'width:280px;flex-shrink:0;padding:16px;border-left:1px solid #e2e8f0;overflow-y:auto;background:#f8fafc;';
+                    rightCol.innerHTML = ( typeof window.clusterlaneNodePanelEmpty === 'function' ? window.clusterlaneNodePanelEmpty() : '' );
+
+                    layout.appendChild(leftCol);
+                    layout.appendChild(mainCol);
+                    layout.appendChild(rightCol);
+                    inner.appendChild(layout);
+                } else {
+                    layout.style.display = 'flex';
+                }
+
+                var leftCol2     = qs('#clusterlane-expand-left');
+                var mainCol2     = qs('#clusterlane-expand-main');
+                var graphHeader  = qs('.clusterlane-graph-header');
+                if (leftCol2 && graphHeader) leftCol2.appendChild(graphHeader);
+
+                var graphPanel = qs('#clusterlane-modal-graph-panel');
+                var vg   = qs('#clusterlane-vis-graph');
+                var wrap = qs('#clusterlane-graph-canvas-wrap');
+                if (wrap) {
+                    // Make the wrap itself a flex column so vis-graph
+                    // (flex:1) can grow to fill the available space while
+                    // the status/legend bar keeps its natural height below
+                    // it. Without this, vis-graph's height:100% resolves
+                    // against the wrap's auto height (a plain, non-flex
+                    // block) and collapses to 0 — the graph going blank.
+                    wrap.style.cssText = 'position:relative;display:flex;flex-direction:column;flex:1;overflow:hidden;';
+                }
+                if (vg) {
+                    vg.style.height    = '100%';
+                    vg.style.flex      = '1';
+                    vg.style.minHeight = '0'; // allow the flex item to shrink instead of overflowing
+                }
+                if (graphPanel && mainCol2) {
+                    graphPanel.style.cssText = 'display:flex;flex:1;flex-direction:column;overflow:hidden;';
+                    mainCol2.appendChild(graphPanel);
+                }
+
+                // Also hide the header/tabs/members panel while expanded —
+                // the fullscreen view is graph-only — but only HIDE them,
+                // never remove them, so collapsing (or the next open)
+                // always has the real elements to restore.
+                var headerEl  = qs('#clusterlane-modal-header');
+                var tabsEl2   = qs('#clusterlane-modal-tabs');
+                if (headerEl) headerEl.style.display = 'none';
+                if (tabsEl2)  tabsEl2.style.display  = 'none';
+            }
+
+            function collapseToNormal() {
+                var inner = qs('#clusterlane-modal-inner');
+                inner.style.cssText = 'background:#fff;border-radius:8px;padding:28px;width:1100px;max-width:96vw;min-height:75vh;max-height:92vh;overflow-y:auto;position:relative;';
+                inner.dataset.expanded = '0';
+                expandBtn.innerHTML = '' + clusterlaneGlyph( 'search' ) + 'Expand';
+
+                var layout = qs('#clusterlane-expand-layout');
+                if (layout) layout.style.display = 'none';
+
+                var headerEl = qs('#clusterlane-modal-header');
+                var tabsEl2  = qs('#clusterlane-modal-tabs');
+                if (headerEl) headerEl.style.display = '';
+                if (tabsEl2)  tabsEl2.style.display  = '';
+
+                // Move graph-panel (and its analysis header) back to their
+                // normal position, right before the status line.
+                var graphPanel   = qs('#clusterlane-modal-graph-panel');
+                var graphHeader  = qs('.clusterlane-graph-header');
+                var statusAnchor = qs('#clusterlane-modal-status');
+                if (graphPanel) {
+                    if (statusAnchor) inner.insertBefore(graphPanel, statusAnchor);
+                    else inner.appendChild(graphPanel);
+                    graphPanel.style.cssText = ''; // drop the flex-column overrides
+                    graphPanel.style.display = '';  // Expand only ever runs from the Graph tab
+                }
+                if (graphHeader && graphPanel) {
+                    graphPanel.parentNode.insertBefore(graphHeader, graphPanel);
+                }
+
+                var vg = qs('#clusterlane-vis-graph');
+                if (vg) {
+                    vg.style.height    = '420px';
+                    vg.style.flex      = '';
+                    vg.style.minHeight = '';
+                }
+                var wrap = qs('#clusterlane-graph-canvas-wrap');
+                if (wrap) wrap.style.cssText = 'position:relative;';
+            }
+
+            // Exposed so closeModal() (a different module) can force a
+            // collapse before hiding the modal — otherwise closing while
+            // expanded leaves #clusterlane-modal-inner stuck at fullscreen size
+            // with the header/tabs hidden for the next time the modal opens.
+            window.clusterlaneCollapseGraphIfExpanded = function() {
+                var inner = qs('#clusterlane-modal-inner');
+                if (inner && inner.dataset.expanded === '1') collapseToNormal();
+            };
+
+            // Exposed so closeModal() can properly tear down the graph
+            // instance on close. (A previous version of this cleanup lived
+            // in its own `document.addEventListener('click', ...)` handler
+            // that checked for a `.clusterlane-modal-close` CLASS and a
+            // `#clusterlane-modal-overlay` id — neither of which exist anywhere in
+            // this codebase, since the real close button uses an ID, not a
+            // class — so it silently never ran, on top of also removing
+            // itself after what would've been its first firing.)
+            window.clusterlaneResetGraphOnClose = function() {
+                if (visNetwork) { visNetwork.destroy(); visNetwork = null; }
+                if (graphResizeObserver) { graphResizeObserver.disconnect(); graphResizeObserver = null; }
+                currentGraphKey = '';
+                var gh = document.querySelector('.clusterlane-graph-header'); if (gh) gh.remove();
+                var gl = document.querySelector('.clusterlane-graph-legend'); if (gl) gl.remove();
+                var gs = qs('#clusterlane-graph-status'); if (gs) gs.textContent = '';
+
+                currentGapKey = '';
+                lastGapData   = null;
+            };
+
+            expandBtn.addEventListener('click', function() {
+                var inner = qs('#clusterlane-modal-inner');
+                var isExpanded = inner.dataset.expanded === '1';
+
+                if (isExpanded) { collapseToNormal(); } else { expandToFullscreen(); }
+
+                setTimeout(function() {
+                    // The ResizeObserver set up in renderGraph() (still
+                    // watching #clusterlane-vis-graph even though it just moved
+                    // through several reparenting steps above — it observes
+                    // the node itself, not its position in the tree) will
+                    // fire on its own once the new layout's real size lands,
+                    // and re-measure + redraw + fit the canvas correctly.
+                    // This is just a safety net for browsers without
+                    // ResizeObserver support.
+                    if (typeof ResizeObserver === 'undefined' && visNetwork) {
+                        visNetwork.redraw();
+                        visNetwork.fit();
+                    }
+                }, 150);
+            });
+
+            // Tab switching — both panels already exist in PHP HTML.
+            tabs.addEventListener('click', function(e) {
+                var btn = e.target.closest('.clusterlane-tab');
+                if (!btn) return;
+
+                tabs.querySelectorAll('.clusterlane-tab').forEach(function(b) {
+                    b.style.borderBottomColor = 'transparent';
+                    b.style.color = '#555';
+                    b.style.fontWeight = '400';
+                });
+                btn.style.borderBottomColor = '#2271b1';
+                btn.style.color = '#2271b1';
+                btn.style.fontWeight = '600';
+
+                var panels = {
+                    members: qs('#clusterlane-modal-members-panel'),
+                    graph:   qs('#clusterlane-modal-graph-panel'),
+                    gap:     qs('#clusterlane-modal-gap-panel'),
+                };
+                var tab = btn.dataset.tab;
+                Object.keys(panels).forEach(function(name) {
+                    if (panels[name]) panels[name].style.display = ( name === tab ) ? '' : 'none';
+                });
+
+                var eb = qs('#clusterlane-expand-btn');
+                if (eb) eb.style.display = ( tab === 'graph' ) ? 'block' : 'none';
+
+                if (tab === 'graph') {
+                    loadGraph(currentGraphKey);
+                    // If the network is already built, the panel was hidden
+                    // when it rendered — give the browser one frame to
+                    // apply the new display value, then remeasure + fit.
+                    if (visNetwork) {
+                        requestAnimationFrame(function() {
+                            visNetwork.redraw();
+                            visNetwork.fit();
+                        });
+                    }
+                } else if (tab === 'gap') {
+                    loadGap(currentGraphKey);
+                }
+            });
+        }
+
+        function loadGraph(key) {
+            if (!key) return;
+            if (currentGraphKey === key && visNetwork) return; // already loaded
+
+            var container = qs('#clusterlane-vis-graph');
+            var status    = qs('#clusterlane-graph-status');
+            if (!container) return;
+
+            container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#888;">Loading graph…</div>';
+            if (status) status.textContent = '';
+
+            // The graph library ships with the plugin (clusterlane-graph-lib.js,
+            // enqueued as a dependency) — nothing is loaded from outside.
+            if (typeof vis === 'undefined' || !vis.Network) {
+                showGraphLibError(container);
+                return;
+            }
+            fetchAndRender(key, container, status);
+        }
+
+        function showGraphLibError(container) {
+            if (!container) return;
+            container.innerHTML = '<div style="color:#b32d2e;padding:16px;line-height:1.5;">'
+                + '\u26a0\ufe0f The graph library failed to load.<br>'
+                + 'Check that <code>admin/js/clusterlane-graph-lib.js</code> is present and not 0 bytes, '
+                + 'and that the vis-network CDN is reachable.'
+                + '</div>';
+            var st = qs('#clusterlane-graph-status');
+            if (st) st.textContent = 'Graph library unavailable.';
+        }
+
+        function fetchAndRender(key, container, status) {
+            var xhr = new XMLHttpRequest();
+            xhr.open('POST', clusterlaneData.restUrl + 'graph/' + key);
+            xhr.setRequestHeader('X-WP-Nonce', clusterlaneData.nonce);
+            xhr.setRequestHeader('Content-Type', 'application/json');
+            xhr.onload = function() {
+                try {
+                    var data = JSON.parse(xhr.responseText);
+                    if (!data.nodes || !data.nodes.length) {
+                        container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#888;">No graph data — click Scan in the bar at the top first.</div>';
+                        return;
+                    }
+                    lastGraphData = data;
+                    if (window.clusterlaneGraphNote) window.clusterlaneGraphNote(container, data);
+                    renderGraphHeader(data, container);
+                    renderGraph(data, container, status);
+                } catch(e) {
+                    container.innerHTML = '<div style="color:#b32d2e;padding:16px;">Error loading graph.</div>';
+                }
+            };
+            xhr.onerror = function() {
+                container.innerHTML = '<div style="color:#b32d2e;padding:16px;">Network error.</div>';
+            };
+            xhr.send('{}');
+        }
+
+        /* ------------------------------------------------------------------
+           Gap Analysis tab
+        ------------------------------------------------------------------ */
+
+        function escGap(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+
+        function loadGap(key, force) {
+            if (!key) return;
+            var content = qs('#clusterlane-gap-content');
+            if (!content) return;
+
+            if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+                var gEx = clusterlaneProExample( 'gap' );
+                if ( gEx ) {
+                    renderGap( gEx, key );
+                    content.insertAdjacentHTML( 'afterbegin', clusterlaneProBanner( 'Gap Analysis' ) );
+                    clusterlaneFreezeExample( content );
+                }
+                return;
+            }
+
+            if (!force && currentGapKey === key && lastGapData) {
+                renderGap(lastGapData, key);
+                return;
+            }
+
+            // Live gap analysis (Pro): clusterlane-pro.js
+            window.clusterlanePro.dashboardGapLoad( { key: key, force: force, content: content, renderGap: renderGap, renderGapError: renderGapError,
+                remember: function( k, d ) { currentGapKey = k; lastGapData = d; } } );
+        }
+
+        function renderGapError(message, key) {
+            var content = qs('#clusterlane-gap-content');
+            if (!content) return;
+
+            var isSettingsIssue = /API key/i.test(message);
+            content.innerHTML = '<div class="clusterlane-gap-error">'
+                + '' + clusterlaneGlyph( 'alert' ) + '' + escGap(message)
+                + ( isSettingsIssue
+                    ? ( clusterlaneCanManage() ? ' <a href="' + ( clusterlaneData.settingsUrl || '#' ) + '">Open Settings →</a>' : '' )
+                    : ' <button type="button" class="clusterlane-gap-retry">Retry</button>' )
+                + '</div>';
+
+            var retryBtn = content.querySelector('.clusterlane-gap-retry');
+            if (retryBtn) retryBtn.addEventListener('click', function() { loadGap(key, true); });
+        }
+
+        function renderGap(data, key) {
+            var content = qs('#clusterlane-gap-content');
+            if (!content) return;
+
+            var badge = data.cached
+                ? '<span class="clusterlane-gap-badge clusterlane-gap-badge--cached">Cached</span>'
+                : '<span class="clusterlane-gap-badge clusterlane-gap-badge--fresh">Freshly generated</span>';
+
+            var html = '<div class="clusterlane-gap-toolbar">'
+                + '<div>' + badge + '</div>'
+                + '<button type="button" class="clusterlane-gap-regenerate" title="Re-run the analysis (uses a new AI call)">' + clusterlaneGlyph( 'sparkle' ) + 'Regenerate</button>'
+                + '</div>';
+
+            html += '<div class="clusterlane-gap-analysis">' + escGap(data.analysis) + '</div>';
+
+            if (data.suggestions && data.suggestions.length) {
+                html += '<div class="clusterlane-gap-suggestions">';
+                data.suggestions.forEach(function(s, i) {
+                    html += '<div class="clusterlane-gap-card" data-idx="' + i + '">'
+                        + '<div class="clusterlane-gap-card-title">' + escGap(s.title) + '</div>'
+                        + ( s.rationale ? '<div class="clusterlane-gap-card-rationale">' + escGap(s.rationale) + '</div>' : '' )
+                        + '<button type="button" class="clusterlane-gap-draft-btn" data-title="' + escGap(s.title) + '" data-rationale="' + escGap(s.rationale || '') + '">' + clusterlaneGlyph( 'sparkle' ) + 'Draft this article</button>'
+                        + '</div>';
+                });
+                html += '</div>';
+            } else {
+                html += '<p style="color:#888;font-size:.85rem;">No specific suggestions returned — try regenerating.</p>';
+            }
+
+            content.innerHTML = html;
+
+            // Regenerate / Draft actions (Pro): clusterlane-pro.js
+            if ( window.clusterlanePro && window.clusterlanePro.dashboardGapBind ) window.clusterlanePro.dashboardGapBind( { content: content, key: key, loadGap: loadGap } );
+        }
+
+
+        function renderGraphHeader(data, container) {
+            var existing = document.querySelector('.clusterlane-graph-header');
+            if (existing) existing.remove();
+            if (!data.cluster) return;
+            var c = data.cluster;
+
+            // Only Links is genuinely graph-specific (it needs the edge
+            // list from this REST call); Entropy/Gini/Density are already
+            // shown as soon as the modal opens, via renderModalStats().
+            updateModalLinkStat( ( data.edges || [] ).length );
+
+            var analysisEl = buildAnalysis(c);
+            if (analysisEl) {
+                var header = document.createElement('div');
+                header.className = 'clusterlane-graph-header';
+                header.style.cssText = 'margin-bottom:10px;';
+                header.appendChild(analysisEl);
+                // Insert above the canvas wrap (not above #clusterlane-vis-graph
+                // itself) — the wrap is a positioned ancestor shared with
+                // the Expand button and the status/legend overlay, so
+                // inserting inside it would push their anchor point down
+                // and cause them to overlap this text instead of the canvas.
+                var wrap = qs('#clusterlane-graph-canvas-wrap') || container.parentNode;
+                wrap.parentNode.insertBefore(header, wrap);
+            }
+        }
+
+        // Appends a single Links chip to the modal's shared #clusterlane-modal-stats
+        // row once the graph's edge count is known. Dedupes on every call so
+        // reloading the graph doesn't stack chips; a fresh openModal() call
+        // replaces the whole row anyway via innerHTML, so no cleanup is
+        // needed there.
+        function updateModalLinkStat(linkCount) {
+            var statsEl = qs('#clusterlane-modal-stats');
+            if (!statsEl) return;
+            statsEl.querySelectorAll('.clusterlane-modal-stat--links').forEach(function(el) { el.remove(); });
+
+            // A divider only makes sense if this is the first stat after the
+            // base row (no Entropy/Gini/Density were available) — otherwise
+            // those already carry the divider and Links just continues the group.
+            var needsDivider = !statsEl.querySelector('.clusterlane-modal-stat--graph-first');
+            var span = document.createElement('span');
+            span.className = 'clusterlane-modal-stat clusterlane-modal-stat--links' + ( needsDivider ? ' clusterlane-modal-stat--graph-first' : '' );
+            span.innerHTML = 'Links: <strong>' + linkCount + '</strong>';
+            statsEl.appendChild(span);
+        }
+
+        function buildAnalysis(cluster) {
+            if (!cluster || !cluster.archetype) return null;
+            var a = cluster.archetype;
+            var m = parseInt(cluster.members) || 0;
+            var h = parseInt(cluster.health)  || 0;
+            var art = m === 1 ? 'article' : 'articles'; // singular/plural for "1 article" vs "N articles"
+            var texts = {
+                'Emerging Cluster':     'With ' + m + ' ' + art + ' and a health score of ' + h + '%, this cluster is still taking shape. Prioritize creating 2-3 cornerstone articles and link them to each other to establish a foundation.',
+                'Balanced Mesh':        'Link authority is evenly spread across ' + m + ' ' + art + ' (' + h + '% health). The structure is solid - boost density by finding unlinked mentions with the Text Match Scanner, and consider designating one strong article as the cluster pillar.',
+                'Authority Pyramid':    'A clear pillar dominates this ' + m + '-article cluster (' + h + '% health). Healthy structure - make sure all support articles link back to the pillar, and use AI Gap Analysis to find missing subtopics.',
+                'Over-Centralized Hub': 'One article carries too much of the link weight across ' + m + ' ' + art + ' (' + h + '% health). Reduce dependency by strengthening 2-3 support articles and adding cross-links between them.',
+                'Weak Structure':       'Internal link density is low across ' + m + ' ' + art + ' (' + h + '% health). Run Text Match to surface unlinked mentions - even adding 5-10 links can significantly improve cluster cohesion.',
+                'Fragmented':           m + ' ' + art + ', ' + h + '% health - several nodes are orphaned with no internal links. Linking orphans into the cluster is the single highest-impact action you can take right now.',
+                'Developed Cluster':    'A mature, well-connected cluster with ' + m + ' ' + art + ' and ' + h + '% health. Focus shifts to content gaps and freshness - use AI Gap Analysis to identify missing subtopics and update older articles.',
+                'Product Range':        'A WooCommerce product category: ' + m + ' products, ' + h + '% health — half the product pages’ own scores, half the share of products that articles link to. Run Text Match on your articles to link the products they already mention, and give short product pages a longer description and gallery images.',
+                'Mixed':                'No single pattern stands out across ' + m + ' ' + art + ' (' + h + '% health) — links are spread fairly evenly without clear main articles. Choose a pillar in Cluster Inspector, link the other articles to it, and use the Text Match Scanner to add links where articles already mention each other.',
+                'Unclassified':         'Click Scan in the bar at the top to classify this cluster and unlock structural insights.',
+            };
+            var text = texts[a] || texts['Unclassified'];
+            var wrap = document.createElement('div');
+            wrap.className = 'clusterlane-graph-analysis';
+            wrap.style.cssText = 'padding:8px 12px;background:#f0f6fc;border-left:3px solid #2271b1;border-radius:0 4px 4px 0;font-size:.81rem;color:#1d2327;line-height:1.5;';
+            var label = document.createElement('strong');
+            label.style.color = '#2271b1';
+            label.textContent = a + ' · ';
+            wrap.appendChild(label);
+            wrap.appendChild(document.createTextNode(text));
+            return wrap;
+        }
+
+        function renderGraph(data, container, status) {
+            container.innerHTML = '';
+            var gs = (window.clusterlaneData && clusterlaneData.graphSettings) ? clusterlaneData.graphSettings : {};
+            var avoidOverlap   = parseFloat( gs.avoid_overlap   ?? 1.1  );
+            var springLength   = parseFloat( gs.spring_length   ?? 180  );
+            var centralGravity = parseFloat( gs.central_gravity ?? 0.12 );
+            var repulsion      = parseFloat( gs.repulsion       ?? 320  );
+            var iterations     = parseInt(   gs.iterations      ?? 220  );
+            var graphHeight    = parseInt(   gs.graph_height    ?? 420  );
+
+            // Apply saved height to the canvas container
+            container.style.height = graphHeight + 'px';
+            var rawNodes = data.nodes || [];
+            var rawEdges = data.edges || [];
+
+            var byGroup = { pillar:[], support:[], light:[], orphan:[] };
+            var hubNode = null;
+            rawNodes.forEach(function(n) {
+                // The category/tag hub node (if present) isn't a post and
+                // doesn't belong in the pillar/support/light/orphan rings —
+                // it gets pinned to the center instead, see below.
+                if (n.class === 'hub') { hubNode = n; return; }
+                var g = n.class || 'light';
+                if (!byGroup[g]) g = 'light';
+                byGroup[g].push(n);
+            });
+
+            var layoutCfg = {
+                pillar:  { r: Math.max(120, byGroup.pillar.length  * 45) },
+                support: { r: Math.max(210, byGroup.support.length * 28) },
+                light:   { r: Math.max(310, byGroup.light.length   * 22) },
+                orphan:  { r: Math.max(400, byGroup.orphan.length  * 20) },
+            };
+
+            // Order nodes within a ring by shared connections instead of
+            // arbitrary array order (post_ids just as returned by the DB
+            // query), so nodes that actually link to each other start out
+            // angularly close together — physics still adjusts things
+            // during stabilization, but a connectivity-aware starting
+            // order gives it a much better seed to converge from, instead
+            // of leaving it to untangle a random arrangement. Greedy
+            // nearest-neighbor chain: start anywhere, always append
+            // whichever remaining node shares the most edges with what's
+            // already placed.
+            function orderByConnectivity(group) {
+                if (group.length <= 2) return group;
+
+                var idSet = {};
+                group.forEach(function(n) { idSet[n.id] = true; });
+
+                var adj = {};
+                group.forEach(function(n) { adj[n.id] = {}; });
+                rawEdges.forEach(function(e) {
+                    if (idSet[e.from] && idSet[e.to] && e.from !== e.to) {
+                        adj[e.from][e.to] = true;
+                        adj[e.to][e.from] = true;
+                    }
+                });
+
+                var byId = {};
+                group.forEach(function(n) { byId[n.id] = n; });
+
+                var remaining = group.map(function(n) { return n.id; });
+                var ordered = [remaining.shift()];
+                while (remaining.length) {
+                    var bestIdx = 0, bestScore = -1;
+                    remaining.forEach(function(id, idx) {
+                        var score = 0;
+                        ordered.forEach(function(placedId) {
+                            if (adj[id][placedId]) score++;
+                        });
+                        if (score > bestScore) { bestScore = score; bestIdx = idx; }
+                    });
+                    ordered.push(remaining.splice(bestIdx, 1)[0]);
+                }
+
+                return ordered.map(function(id) { return byId[id]; });
+            }
+
+            var startAngle = -Math.PI / 2;
+            ['pillar','support','light','orphan'].forEach(function(type) {
+                var group = orderByConnectivity(byGroup[type]);
+                var r = layoutCfg[type].r;
+                group.forEach(function(n, i) {
+                    var angle = startAngle + (2 * Math.PI * i) / Math.max(1, group.length);
+                    n.x = Math.cos(angle) * r;
+                    n.y = Math.sin(angle) * r * 0.82;
+                    n.is_orphan = (type === 'orphan');
+                });
+            });
+
+            // Pin the hub node at the visual center — the radial rings
+            // for pillar/support/light/orphan all start at r >= 120, so
+            // (0,0) is naturally clear space. It's locked (not just
+            // initially placed) because it usually has few or no
+            // real inbound edges, so physics alone won't hold it there.
+            if (hubNode) {
+                hubNode.x = 0;
+                hubNode.y = 0;
+                hubNode.is_orphan = false;
+                hubNode.fixed = { x: true, y: true };
+            }
+
+            rawNodes.forEach(function(n) {
+                n.title = n.title || (n.label + '\nInbound: ' + (n.inbound || 0));
+            });
+
+            var nodes = new vis.DataSet(rawNodes);
+            var originalColors = {};
+            rawNodes.forEach(function(n) { originalColors[n.id] = n.color; });
+            var edges = new vis.DataSet(rawEdges);
+
+            var edgeUpdates = [];
+            edges.forEach(function(e) {
+                var w = e.weight || 1;
+                edgeUpdates.push({ id: e.id, width: Math.min(w,5), color: w > 3 ? '#1e88e5' : '#b0b7c3' });
+            });
+            edges.update(edgeUpdates);
+
+            // Derive a stable integer seed from the cluster key so the
+            // physics simulation starts from the same point every reload.
+            var graphSeed = 0;
+            var seedStr = String(currentGraphKey || '');
+            for (var si = 0; si < seedStr.length; si++) {
+                graphSeed = ((graphSeed << 5) - graphSeed + seedStr.charCodeAt(si)) | 0;
+            }
+            graphSeed = Math.abs(graphSeed) || 1;
+
+            visNetwork = new vis.Network(container, { nodes: nodes, edges: edges }, {
+                layout: { improvedLayout: true, randomSeed: graphSeed },
+                nodes: { shape:'dot', size:18, borderWidth:1.5, font:{ size:12, color:'#333', vadjust:-26, face:'system-ui,sans-serif' } },
+                edges: { arrows:{ to:{ enabled:true, scaleFactor:0.8 } }, smooth:{ type:'dynamic', roundness:0.22 }, width:1.5, color:'#b0b7c3' },
+                physics: { enabled:true, minVelocity:0.75, stabilization:{ iterations: iterations }, barnesHut:{ gravitationalConstant: -repulsion, centralGravity: centralGravity, springLength: springLength, springConstant:0.04, damping:0.72, avoidOverlap: avoidOverlap } },
+                interaction: { hover:true, dragNodes:true, dragView:true, zoomView:true, tooltipDelay:80 },
+            });
+
+            // The container sits inside a flex layout (the small modal box,
+            // or the 3-column Expand layout) whose final pixel size can keep
+            // settling for a tick or two after this network is constructed —
+            // vis-network sizes its canvas's internal pixel buffer from a
+            // snapshot of the container's size, and if that snapshot is
+            // taken before the flex layout has actually settled, the buffer
+            // ends up smaller than the box CSS later grows it to, and the
+            // browser stretches it — that's the blur. Instead of guessing a
+            // timeout, watch the container's REAL size and re-measure the
+            // canvas exactly when it actually changes.
+            if (graphResizeObserver) { graphResizeObserver.disconnect(); }
+            var resizeDebounce = null;
+            graphResizeObserver = new ResizeObserver(function() {
+                clearTimeout(resizeDebounce);
+                resizeDebounce = setTimeout(function() {
+                    if (!visNetwork) return;
+                    visNetwork.redraw();
+                    visNetwork.fit();
+                }, 60);
+            });
+            graphResizeObserver.observe(container);
+
+            var ringCfg = [
+                { r: layoutCfg.pillar.r,  fill: 'rgba(231,108,46,0.06)'  },
+                { r: layoutCfg.support.r, fill: 'rgba(245,200,66,0.05)'  },
+                { r: layoutCfg.light.r,   fill: 'rgba(144,184,101,0.04)' },
+            ];
+            visNetwork.on('beforeDrawing', function(ctx) {
+                ringCfg.forEach(function(ring) {
+                    ctx.beginPath(); ctx.arc(0, 0, ring.r, 0, Math.PI * 2);
+                    ctx.fillStyle = ring.fill; ctx.fill();
+                });
+            });
+
+            var pulse = 0;
+            var pulseTimer = setInterval(function() { pulse = (pulse + 0.02) % Math.PI; }, 50); clusterlanePulseTimer = pulseTimer;
+
+            visNetwork.on('afterDrawing', function(ctx) {
+                if (!visNetwork) return;
+                var positions = visNetwork.getPositions();
+                nodes.forEach(function(n) {
+                    var pos = positions[n.id]; if (!pos) return;
+                    var sz = n.value || 18;
+                    var inbound = n.inbound || 0;
+                    if (inbound > 2) {
+                        var intensity = Math.min(0.35, (inbound / 20) * 0.35);
+                        ctx.beginPath(); ctx.arc(pos.x, pos.y, sz * 1.5, 0, Math.PI * 2);
+                        ctx.fillStyle = 'rgba(255,165,0,' + intensity + ')'; ctx.fill();
+                    }
+                    if (n.is_orphan) {
+                        var alpha = 0.15 + Math.sin(pulse) * 0.1;
+                        ctx.beginPath(); ctx.arc(pos.x, pos.y, sz * 1.9, 0, Math.PI * 2);
+                        ctx.strokeStyle = 'rgba(0,150,255,' + alpha + ')'; ctx.lineWidth = 2; ctx.stroke();
+                    }
+                });
+            });
+
+            function resetHighlight() {
+                nodes.forEach(function(n) { nodes.update({ id:n.id, hidden:false, color:originalColors[n.id], font:{ size:12, color:'#333' }, borderWidth:1.5 }); });
+                var eu = [];
+                edges.forEach(function(e) { var w=e.weight||1; eu.push({ id:e.id, hidden:false, width:Math.min(w,5), color:w>3?'#1e88e5':'#b0b7c3' }); });
+                edges.update(eu);
+            }
+
+            var dashFull = function(n) {
+                var t = typeof n.title === 'string' ? n.title.split('\n')[0] : String(n.label || '');
+                return t.replace(/ \(category hub\)$/, '');
+            };
+            rawNodes.forEach(function(n) {
+                if (n.score == null && typeof n.title === 'string') { var sm = n.title.match(/Score:\s*(\d+)/); n.score = sm ? parseInt(sm[1], 10) : null; }
+            });
+            function selectDashNode(sel) {
+                var neighbors = visNetwork.getConnectedNodes(sel);
+                var connEdges = visNetwork.getConnectedEdges(sel);
+                nodes.forEach(function(n) {
+                    var active = (n.id === sel || neighbors.indexOf(n.id) !== -1);
+                    nodes.update({ id:n.id, color:active ? n.color : { background:'rgba(200,200,200,0.15)', border:'rgba(180,180,180,0.3)' }, font:active ? { size:14, color:'#000' } : { color:'rgba(0,0,0,0.25)' }, borderWidth:active ? 3 : 1 });
+                });
+                edges.forEach(function(e) { edges.update({ id:e.id, hidden:connEdges.indexOf(e.id)===-1 }); });
+
+                // Article panel (shared with Cluster Inspector, 0.98.199)
+                var rightPanel = qs('#clusterlane-expand-right');
+                if (rightPanel && typeof window.clusterlaneNodePanel === 'function') {
+                    window.clusterlaneNodePanel(rightPanel, {
+                        node: rawNodes.find(function(n) { return n.id === sel; }),
+                        nodes: rawNodes, edges: rawEdges,
+                        full: dashFull, short: dashFull,
+                        inspectorUrl: clusterlaneData.articleInspectorUrl || '',
+                        onPick: function(id) {
+                            visNetwork.selectNodes([id]);
+                            visNetwork.focus(id, { scale: Math.max(visNetwork.getScale(), 0.6), animation: { duration: 350, easingFunction: 'easeInOutQuad' } });
+                            selectDashNode(id);
+                        }
+                    });
+                }
+            }
+
+            visNetwork.on('click', function(params) {
+                if (!params.nodes.length) { resetHighlight(); var rp = qs("#clusterlane-expand-right"); if (rp) rp.innerHTML = (typeof window.clusterlaneNodePanelEmpty === 'function' ? window.clusterlaneNodePanelEmpty() : ''); return; }
+                selectDashNode(params.nodes[0]);
+            });
+            visNetwork.on('doubleClick', function(params) {
+                if (params.nodes.length) {
+                    var node = rawNodes.find(function(n) { return n.id === params.nodes[0]; });
+                    if (node && node.url) window.open(node.url, '_blank');
+                } else { resetHighlight(); }
+            });
+
+            function buildLegend() {
+                var leg = document.createElement('div');
+                [
+                    { color:'#e76c2e', label:'Pillar' },
+                    { color:'#f5c842', label:'Support' },
+                    { color:'#90b865', label:'Light' },
+                    { color:'#5ba4cf', label:'Orphan' },
+                    { color:'rgba(255,165,0,0.45)', label:'High inbound' },
+                    { color:'rgba(0,150,255,0.4)',  label:'Orphan signal', ring:true },
+                    { color:'#6f5bb0', label:'Category hub', diamond:true },
+                ].forEach(function(item) {
+                    var dot = document.createElement('span');
+                    dot.className = 'clusterlane-graph-legend-item';
+                    var icon = document.createElement('span');
+                    icon.style.cssText = item.ring
+                        ? 'width:10px;height:10px;border-radius:50%;border:2px solid ' + item.color + ';display:inline-block;flex-shrink:0;'
+                        : item.diamond
+                        ? 'width:8px;height:8px;background:' + item.color + ';display:inline-block;transform:rotate(45deg);flex-shrink:0;'
+                        : 'width:9px;height:9px;border-radius:50%;background:' + item.color + ';display:inline-block;flex-shrink:0;';
+                    dot.appendChild(icon);
+                    dot.appendChild(document.createTextNode(item.label));
+                    leg.appendChild(dot);
+                });
+                return leg;
+            }
+
+            visNetwork.once('stabilizationIterationsDone', function() {
+                visNetwork.setOptions({ physics: { enabled: false } });
+                visNetwork.fit({ animation: { duration:300, easingFunction:'easeInOutQuad' } });
+                if (status) {
+                    status.textContent = 'Click node to highlight · Click background to reset · Double-click to edit';
+                    var parent = status.parentNode;
+                    if (parent && !parent.querySelector('.clusterlane-graph-legend')) {
+                        var leg = buildLegend();
+                        leg.className = 'clusterlane-graph-legend';
+                        parent.appendChild(leg);
+                    }
+                }
+            });
+
+            // Graph cleanup on modal close now happens via
+            // window.clusterlaneResetGraphOnClose, called from closeModal() in the
+            // other module — see that function for why the previous
+            // listener here never actually fired.
+        }
+        document.addEventListener('click', function(e) {
+            var btn = e.target.closest('.clusterlane-edit-cluster');
+            if (!btn) return;
+            currentGraphKey = btn.dataset.key;
+            visNetwork = null; // Reset so graph reloads for new cluster.
+            currentGapKey = '';
+            lastGapData   = null; // Reset so Gap reloads for new cluster.
+            setTimeout(ensureGraphTab, 50); // After modal opens.
+        });
+
+        // "All Clusters" table filter — text search + the three type
+        // buttons combine (AND): typing narrows within the active type,
+        // and clicking an already-active type button clears it back to
+        /* ── Quick Navigation autocomplete ──────────────────────────── */
+        (function() {
+            // Pre-load all clusters once for fast local filtering.
+            var allClusters = [];
+            clusterlaneDashClustersLoad()
+                .then(function(d) { allClusters = (d && d.clusters) || []; })
+                .catch(function() {});
+
+            function showDropdown(results, items, buildRow) {
+                if (!items.length) { results.style.display = 'none'; return; }
+                results.innerHTML = items.slice(0, 8).map(buildRow).join('');
+                results.style.display = '';
+            }
+
+            function setupNavSearch(inputId, resultsId, getItems, buildRow) {
+                var input   = qs('#' + inputId);
+                var results = qs('#' + resultsId);
+                if (!input || !results) return;
+
+                var timer;
+                input.addEventListener('input', function() {
+                    clearTimeout(timer);
+                    var q = input.value.trim();
+                    if (q.length < 2) { results.style.display = 'none'; return; }
+                    timer = setTimeout(function() { getItems(q, function(items) { showDropdown(results, items, buildRow); }); }, 220);
+                });
+
+                document.addEventListener('click', function(e) {
+                    if (!input.contains(e.target) && !results.contains(e.target)) {
+                        results.style.display = 'none';
+                    }
+                });
+            }
+
+            function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+
+            // Cluster search — local filter
+            setupNavSearch(
+                'clusterlane-dash-cluster-search',
+                'clusterlane-dash-cluster-results',
+                function(q, cb) {
+                    var ql = q.toLowerCase();
+                    cb( allClusters.filter(function(c) {
+                        return (c.cluster_label||'').toLowerCase().indexOf(ql) !== -1;
+                    }) );
+                },
+                function(c) {
+                    var health = parseInt(c.health_score, 10) || 0;
+                    var hCol   = health >= 70 ? '#16a34a' : health >= 50 ? '#ca8a04' : '#dc2626';
+                    var url    = (clusterlaneData.clusterInspectorUrl || '') + '&open=' + encodeURIComponent(c.cluster_key || '');
+                    return '<a href="' + url + '" class="clusterlane-dash-nav-result">'
+                         + '<span class="clusterlane-dash-nav-result-label">' + esc(c.cluster_label) + '</span>'
+                         + '<span style="font-size:11px;font-weight:700;color:' + hCol + ';">' + health + '%</span>'
+                         + '</a>';
+                }
+            );
+
+
+        }());
+
+        /* ── Site Analysis ──────────────────────────────────────────── */
+        (function() {
+            var btn   = document.getElementById('clusterlane-site-analysis-btn');
+            var regen = document.getElementById('clusterlane-site-analysis-regen');
+            var body  = document.getElementById('clusterlane-site-analysis-body');
+            var result= document.getElementById('clusterlane-site-analysis-result');
+            var meta  = document.getElementById('clusterlane-site-analysis-meta');
+            if (!btn) return;
+
+            function esc(str) {
+                return String(str || '').trim().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+            }
+
+            /* Cluster names in the analysis link to that cluster in Cluster
+               Inspector (?open=key). Never in the Free example: its demo
+               clusters are not this site's, even if a name happens to match. */
+            var CI_URL = clusterlaneData.clusterInspectorUrl || '';
+            var linkClusters = false;
+            var lastLive = null;
+            function ciHref(key) { return CI_URL + '&open=' + encodeURIComponent(key); }
+            function labelMap() {
+                var m = {};
+                (window.clusterlaneDashClusters || []).forEach(function(c) {
+                    var l = String(c.cluster_label || '').trim();
+                    if (l && c.cluster_key && !m[l]) m[l] = c.cluster_key;
+                });
+                return m;
+            }
+            function keyFor(label, key) {
+                if (!linkClusters || !CI_URL) return '';
+                if (key) return key;
+                return labelMap()[String(label || '').trim()] || '';
+            }
+            // Escape, then link the first mention of each cluster name (longest
+            // names first, whole words, 4+ characters, case-sensitive).
+            function escLinked(text) {
+                var out = esc(text);
+                if (!linkClusters || !CI_URL) return out;
+                var m = labelMap();
+                var labels = Object.keys(m).filter(function(l) { return l.length >= 4; })
+                    .sort(function(a, b) { return b.length - a.length; });
+                if (!labels.length) return out;
+                var used = [];
+                labels.forEach(function(l) {
+                    var el = esc(l).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+                    var re = new RegExp('(^|[^\\p{L}\\p{N}_>])(' + el + ')(?![\\p{L}\\p{N}_<])', 'u');
+                    var hit = re.exec(out);
+                    if (!hit) return;
+                    var at = hit.index + hit[1].length;
+                    // Skip if inside a link we already added.
+                    for (var i = 0; i < used.length; i++) { if (at >= used[i][0] && at < used[i][1]) return; }
+                    var a = '<a class="clusterlane-sa-cluster-link" href="' + ciHref(m[l]) + '">' + hit[2] + '</a>';
+                    out = out.slice(0, at) + a + out.slice(at + hit[2].length);
+                    used = used.map(function(u) { return u[0] > at ? [u[0] + a.length - hit[2].length, u[1] + a.length - hit[2].length] : u; });
+                    used.push([at, at + a.length]);
+                });
+                return out;
+            }
+
+            /* "Go" buttons: each opportunity/action names the Clusterlane tool to
+               act with (and the clusters it is about). Cluster tools open that
+               cluster in Cluster Inspector on the right block (?focus=).
+               Never in the Free example — its clusters are the demo site's. */
+            var ADMIN = CI_URL ? CI_URL.split('?')[0] + '?page=' : 'admin.php?page=';
+            var TOOL = {
+                improve:          { label: function (c) { return c ? 'Improve ' + c : 'Improve this cluster'; }, focus: 'improve', glyph: 'bolt' },
+                pillar:           { label: function (c) { return c ? 'Choose the pillar in ' + c : 'Choose a pillar'; }, focus: 'pillar', glyph: 'award' },
+                gap:              { label: function (c) { return c ? 'Find missing articles in ' + c : 'Find missing articles'; }, focus: 'gap', glyph: 'bulb' },
+                text_match:       { label: function () { return 'Link mentions in Text Match'; }, page: 'clusterlane-text-match', glyph: 'search' },
+                link_suggestions: { label: function () { return 'Open Link Suggestions'; }, page: 'clusterlane-link-suggest', glyph: 'link' },
+                cluster_admin:    { label: function () { return 'Open Cluster Admin'; }, page: 'clusterlane-cluster-settings', glyph: 'layers', manage: true }
+            };
+            function toolButtons(item) {
+                if (!item || !item.tool || !TOOL[item.tool]) return '';
+                var t = TOOL[item.tool], out = [];
+                if (linkClusters && t.manage && !clusterlaneCanManage()) return '';
+                // Free example: the same buttons, shown but inert (demo clusters don't exist here).
+                var btn = function (href, text) {
+                    return linkClusters
+                        ? '<a class="clusterlane-sa-go" href="' + esc(href) + '">' + clusterlaneGlyph(t.glyph) + esc(text) + ' →</a>'
+                        : '<span class="clusterlane-sa-go is-example" aria-disabled="true" title="Example only">' + clusterlaneGlyph(t.glyph) + esc(text) + ' →</span>';
+                };
+                if (t.focus) {
+                    (item.targets || []).forEach(function (c) { out.push(btn(ciHref(c.key) + '&focus=' + t.focus, t.label(c.label))); });
+                } else {
+                    out.push(btn(ADMIN + t.page, t.label()));
+                }
+                return out.length ? '<span class="clusterlane-sa-go-row">' + out.join('') + '</span>' : '';
+            }
+
+            function renderAnalysis(data, siteNameOverride) {
+                    linkClusters = !siteNameOverride; // the Free example passes its demo site name
+                    if (linkClusters) lastLive = data;
+                    var html = '<div class="clusterlane-site-analysis-body-inner">';
+                    html += '<div class="clusterlane-sa-overview"><p class="clusterlane-sa-section-title">' + clusterlaneGlyph( 'clipboard' ) + 'Summary: ' + esc(siteNameOverride || ((typeof clusterlaneStickyBar !== 'undefined' && clusterlaneStickyBar.siteName) ? clusterlaneStickyBar.siteName : (typeof clusterlaneData !== 'undefined' && clusterlaneData.siteName) ? clusterlaneData.siteName : 'Site Overview')) + '</p>' + escLinked(data.overview) + '</div>';
+                    if (data.strengths && data.strengths.length) {
+                        html += '<div class="clusterlane-sa-strengths"><p class="clusterlane-sa-section-title">' + clusterlaneGlyph( 'check' ) + 'Strengths</p><div class="clusterlane-sa-strength-grid">';
+                        data.strengths.forEach(function(s) {
+                            // "Label: explanation" — or, when a model skips the label, the
+                            // first short sentence becomes the label (never a bare check mark).
+                            var str = String(s || '').trim(), title = '', rest = str;
+                            var colon = str.indexOf(':');
+                            if (colon > 0 && colon <= 90) { title = str.substring(0, colon); rest = str.substring(colon + 1).trim(); rest = rest.charAt(0).toUpperCase() + rest.slice(1); }
+                            else {
+                                var m = str.match(/^(.{12,110}?[.!?])\s+(\S[\s\S]*)$/);
+                                if (m) { title = m[1].replace(/[.]$/, ''); rest = m[2]; }
+                            }
+                            html += '<div class="clusterlane-sa-strength">'
+                                + (title ? '<strong>✓ ' + esc(title) + '</strong><span>' + escLinked(rest) + '</span>'
+                                         : '<span>✓ ' + escLinked(rest) + '</span>')
+                                + '</div>';
+                        });
+                        html += '</div></div>';
+                    }
+                    // Weak clusters chip list
+                    if (data.weak_clusters && data.weak_clusters.length) {
+                        html += '<div class="clusterlane-sa-weak"><p class="clusterlane-sa-section-title">' + clusterlaneGlyph( 'alert' ) + 'Needs attention</p><div class="clusterlane-sa-weak-chips">';
+                        data.weak_clusters.forEach(function(c) {
+                            var cls = c.health < 40 ? 'clusterlane-sa-chip--critical' : 'clusterlane-sa-chip--warn';
+                            var k   = keyFor(c.label, c.key);
+                            var tag = k ? 'a' : 'span';
+                            html += '<' + tag + ' class="clusterlane-sa-chip ' + cls + '"' + (k ? ' href="' + ciHref(k) + '" title="Open in Cluster Inspector"' : '') + '>'
+                                + esc(c.label)
+                                + '<em>' + c.health + '%</em>'
+                                + '</' + tag + '>';
+                        });
+                        html += '</div></div>';
+                    }
+                    if (data.opportunities && data.opportunities.length) {
+                        html += '<div class="clusterlane-sa-opportunities"><p class="clusterlane-sa-section-title">' + clusterlaneGlyph( 'target' ) + 'Opportunities</p><div class="clusterlane-sa-opp-grid">';
+                        data.opportunities.forEach(function(o) {
+                            html += '<div class="clusterlane-sa-opp"><strong>' + escLinked(o.title) + '</strong><span>' + escLinked(o.description) + '</span>' + toolButtons(o) + '</div>';
+                        });
+                        html += '</div></div>';
+                    }
+                    if (data.priority_actions && data.priority_actions.length) {
+                        html += '<div class="clusterlane-sa-actions"><p class="clusterlane-sa-section-title">' + clusterlaneGlyph( 'arrow' ) + 'Priority Actions</p><div class="clusterlane-sa-action-list">';
+                        data.priority_actions.forEach(function(a, i) {
+                            html += '<div class="clusterlane-sa-action">'
+                                + '<span class="clusterlane-sa-action-num">' + (i+1) + '</span>'
+                                + '<div class="clusterlane-sa-action-text"><strong>' + escLinked(a.action) + '</strong><span>' + escLinked(a.rationale) + '</span>' + toolButtons(a) + '</div>'
+                                + '</div>';
+                        });
+                        html += '</div></div>';
+                    }
+                    html += '</div>';
+                    result.innerHTML = html;
+            }
+
+            // The cached analysis can render before the cluster list arrives.
+            clusterlaneDashClustersLoad().then(function(d) {
+                window.clusterlaneDashClusters = (d && d.clusters) || [];
+                if (lastLive && result.innerHTML) renderAnalysis(lastLive);
+            }).catch(function() {});
+
+            // Persist open/closed state across page loads.
+            var card = document.getElementById('clusterlane-site-analysis-card');
+            if (card) {
+                // Restore saved state before auto-load runs.
+                var savedOpen = localStorage.getItem('clusterlane_sa_open');
+                if (savedOpen === 'false') card.open = false;
+
+                card.addEventListener('toggle', function() {
+                    try { localStorage.setItem('clusterlane_sa_open', card.open); } catch(e) {}
+                });
+            }
+
+            // Free edition: show what Pro produces, for a demo site — no AI call.
+            if ( ( ! ( typeof clusterlaneProReady === 'function' && clusterlaneProReady() ) ) ) {
+                var saEx = clusterlaneProExample( 'site_analysis' );
+                btn.style.display = 'none'; regen.style.display = 'none';
+                // Closed card: say it is Pro and that opening it shows an example (0.98.185).
+                var saBadge = btn.parentNode && btn.parentNode.querySelector('.clusterlane-sa-ai-badge');
+                if ( saBadge ) { saBadge.innerHTML = '<span class="clusterlane-pro-badge">PRO</span> See example'; saBadge.classList.add('clusterlane-sa-ai-badge--pro'); }
+                if ( saEx ) {
+                    renderAnalysis( saEx, ( clusterlaneProExample( 'site' ) || '' ) );
+                    result.insertAdjacentHTML( 'afterbegin', clusterlaneProBanner( 'Site Intelligence Analysis' ) );
+                    clusterlaneFreezeExample( result );
+                }
+                return;
+            }
+
+            // Live analysis (Pro): admin/js/pro__premium_only/clusterlane-pro.js
+            window.clusterlanePro.siteAnalysis( { btn: btn, regen: regen, body: body, result: result, meta: meta, renderAnalysis: renderAnalysis, esc: esc } );
+
+        })();
+
+    /* ------------------------------------------------------------------
+       First-run onboarding
+    ------------------------------------------------------------------ */
+    if ( clusterlaneData.onboarding ) document.addEventListener('DOMContentLoaded', function() { (function() {
+        var overlay  = document.getElementById('clusterlane-onboarding-overlay');
+        if ( !overlay ) return;
+
+        var BATCH    = parseInt(clusterlaneData.batchSize, 10) || 25;
+        var REST     = clusterlaneData.restUrl;
+        var NONCE    = clusterlaneData.nonce;
+        var allIds   = [];
+        var scanned  = 0;
+        var errors   = 0;
+        var idx      = 0;
+        var running  = false;
+
+        function apiFetch(path, method, body) {
+            var opts = { method: method || 'GET', headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json' }, credentials: 'same-origin' };
+            if (body) opts.body = JSON.stringify(body);
+            return fetch(REST + path, opts).then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); });
+        }
+
+        function goStep(n) {
+            overlay.querySelectorAll('.clusterlane-ob-step').forEach(function(el) { el.style.display = 'none'; });
+            var el = document.getElementById('clusterlane-ob-step-' + n);
+            if (el) el.style.display = '';
+            overlay.querySelectorAll('.clusterlane-ob-dot').forEach(function(d) {
+                d.classList.toggle('clusterlane-ob-dot--active', parseInt(d.dataset.step, 10) === n);
+                d.classList.toggle('clusterlane-ob-dot--done',   parseInt(d.dataset.step, 10) < n);
+            });
+        }
+
+        // Navigation — event delegation so DOM order doesn't matter.
+        document.addEventListener('click', function(e) {
+            var next = e.target.closest('.clusterlane-ob-next');
+            if (next && overlay.contains(next)) { goStep(parseInt(next.dataset.next, 10)); return; }
+            var back = e.target.closest('.clusterlane-ob-back');
+            if (back && overlay.contains(back)) { goStep(parseInt(back.dataset.back, 10)); return; }
+        });
+
+        // Step 2 (0.98.177): content types, the published-page option and clusters.
+        function tickedTypes() {
+            return Array.prototype.slice.call(overlay.querySelectorAll('.clusterlane-ob-type:checked'));
+        }
+        function refreshStep2() {
+            var types = tickedTypes().map(function(cb) { return cb.value; });
+            var visible = 0;
+            // Pages as clusters (0.98.180): the page type and a type pointing to it must be scanned.
+            overlay.querySelectorAll('.clusterlane-ob-hub').forEach(function(h) {
+                var ht = (h.dataset.types || '').split(' ');
+                h.hidden = !(types.indexOf(ht[0]) !== -1 && ht.slice(1).some(function(t) { return types.indexOf(t) !== -1; }));
+            });
+            overlay.querySelectorAll('.clusterlane-ob-group').forEach(function(g) {
+                var gt = (g.dataset.types || '').split(' ');
+                var show = g.classList.contains('clusterlane-ob-hubs')
+                    ? !!g.querySelector('.clusterlane-ob-hub:not([hidden])')
+                    : gt.some(function(t) { return types.indexOf(t) !== -1; });
+                g.hidden = !show;
+                if (show) visible++;
+            });
+            var none = document.getElementById('clusterlane-ob-no-groups');
+            if (none) none.hidden = visible > 0;
+            var rec = tickedTypes().filter(function(cb) { return cb.dataset.rec === '1'; })
+                .map(function(cb) { return cb.dataset.label || cb.value; });
+            var box = document.getElementById('clusterlane-ob-rendered');
+            var names = document.getElementById('clusterlane-ob-rendered-names');
+            if (box) box.hidden = !rec.length;
+            if (names) names.textContent = rec.join(', ');
+            overlay.querySelectorAll('.clusterlane-ob-group').forEach(function(g) {
+                var btn = g.querySelector('.clusterlane-ob-group-all');
+                if (!btn) return;
+                var cbs = g.querySelectorAll('.clusterlane-ob-cat-check');
+                var all = cbs.length && Array.prototype.every.call(cbs, function(cb) { return cb.checked; });
+                btn.textContent = all ? 'Deselect all' : 'Select all';
+            });
+        }
+        overlay.addEventListener('change', function(e) {
+            if (e.target.classList.contains('clusterlane-ob-type') || e.target.classList.contains('clusterlane-ob-cat-check')) {
+                refreshStep2();
+                var st = document.getElementById('clusterlane-ob-step2-status');
+                if (st) st.textContent = '';
+            }
+        });
+        overlay.addEventListener('click', function(e) {
+            var btn = e.target.closest('.clusterlane-ob-group-all');
+            if (!btn || !overlay.contains(btn)) return;
+            var cbs = btn.closest('.clusterlane-ob-group').querySelectorAll('.clusterlane-ob-cat-check');
+            var all = Array.prototype.every.call(cbs, function(cb) { return cb.checked; });
+            cbs.forEach(function(cb) { cb.checked = !all; });
+            refreshStep2();
+        });
+        refreshStep2();
+
+        // Step 2: Save content types and cluster selection.
+        var saveBtn = document.getElementById('clusterlane-ob-save-clusters');
+        if (saveBtn) saveBtn.addEventListener('click', function() {
+            var s2 = document.getElementById('clusterlane-ob-step2-status');
+            var types = tickedTypes().map(function(cb) { return cb.value; });
+            if (!types.length) {
+                if (s2) s2.textContent = 'Please select at least one kind of content.';
+                return;
+            }
+            // No cluster ticked while there are candidates: ask once (0.98.185) — continuing gives an empty map.
+            var anyGroup = overlay.querySelector('.clusterlane-ob-group:not([hidden]) .clusterlane-ob-cat-check, .clusterlane-ob-hub:not([hidden]) .clusterlane-ob-hub-check');
+            var anyTicked = overlay.querySelector('.clusterlane-ob-group:not([hidden]) .clusterlane-ob-cat-check:checked, .clusterlane-ob-hub:not([hidden]) .clusterlane-ob-hub-check:checked');
+            if (anyGroup && !anyTicked && !saveBtn.dataset.confirmed) {
+                if (s2) s2.textContent = 'No clusters picked — the map will be empty until you add some in Cluster Admin. Click again to continue anyway.';
+                saveBtn.dataset.confirmed = '1';
+                return;
+            }
+            var body = { post_types: types, categories: [], tags: [], terms: {}, rendered_types: [], hubs: [] };
+            overlay.querySelectorAll('.clusterlane-ob-hub:not([hidden]) .clusterlane-ob-hub-check:checked').forEach(function(cb) { body.hubs.push(cb.value); });
+            overlay.querySelectorAll('.clusterlane-ob-group:not([hidden]) .clusterlane-ob-cat-check:checked').forEach(function(cb) {
+                var tax = cb.dataset.tax || 'category';
+                var id  = parseInt(cb.value, 10);
+                if (!id) return;
+                if (tax === 'category') body.categories.push(id);
+                else (body.terms[tax] = body.terms[tax] || []).push(id);
+            });
+            var rbox = document.getElementById('clusterlane-ob-rendered');
+            var rchk = document.getElementById('clusterlane-ob-rendered-check');
+            if (rbox && !rbox.hidden && rchk && rchk.checked) {
+                body.rendered_types = tickedTypes().filter(function(cb) { return cb.dataset.rec === '1'; })
+                    .map(function(cb) { return cb.value; });
+            }
+            if (s2) s2.textContent = '';
+            saveBtn.disabled = true;
+            saveBtn.textContent = 'Saving…';
+
+            apiFetch('onboarding/setup', 'POST', body)
+                .then(function() {
+                    goStep(3);
+                })
+                .catch(function() {
+                    saveBtn.disabled = false;
+                    saveBtn.textContent = 'Save & continue →';
+                    if (s2) s2.textContent = 'Error saving — please try again.';
+                });
+        });
+
+        // Step 3: Scan.
+        var startScanBtn = document.getElementById('clusterlane-ob-start-scan');
+        if (startScanBtn) startScanBtn.addEventListener('click', function() {
+            startScanBtn.style.display = 'none';
+            var pw = document.getElementById('clusterlane-ob-progress-wrap');
+            if (pw) pw.style.display = '';
+
+            apiFetch('scan/posts')
+                .then(function(res) {
+                    allIds = res.ids || [];
+                    window.clusterlaneScanRunner(allIds, BATCH, {
+                        pages: 'now', // the first scan reads the published pages at once, so the map is right from the start
+                        onProgress: function(p) {
+                            var bar   = document.getElementById('clusterlane-ob-progress-bar');
+                            var label = document.getElementById('clusterlane-ob-progress-label');
+                            var pct   = p.total ? Math.round((p.done / p.total) * 100) : 0;
+                            if (bar)   bar.style.width = pct + '%';
+                            if (label) label.textContent = p.done + ' / ' + p.total + ' articles scanned';
+                        },
+                        onDone: function(r) {
+                            scanned = r.scanned; errors = r.failed.length;
+                            goStep(4);
+                            apiFetch('clusters/compute', 'POST')
+                                .then(function(res) {
+                                    var summary = document.getElementById('clusterlane-ob-done-summary');
+                                    if (summary) summary.textContent =
+                                        scanned + ' of ' + r.total + ' pages scanned across ' + (res.computed || 0) + ' clusters.'
+                                        + (errors ? ' ' + errors + ' could not be scanned — click Scan in the bar at the top to retry.' : ' Your content map is ready.')
+                                        + (r.pages && r.pages.failed ? ' ' + r.pages.failed + ' of ' + (r.pages.ok + r.pages.failed) + ' published pages could not be read' + (r.pages.example && r.pages.example.message ? ' (' + r.pages.example.message + ')' : '') + ' — their text links still count. Details: Settings → Content.' : '');
+                                    goStep(5);
+                                })
+                                .catch(function() {
+                                    var s = document.getElementById('clusterlane-ob-step4-status');
+                                    if (s) s.textContent = 'Error updating the cluster figures — click Scan in the bar at the top to try again.';
+                                    goStep(5);
+                                });
+                        }
+                    });
+                })
+                .catch(function() {
+                    var s = document.getElementById('clusterlane-ob-step3-status');
+                    if (s) s.textContent = 'Could not load post list.';
+                    startScanBtn.style.display = '';
+                });
+        });
+
+        // Close button on done step.
+        var closeBtn = document.getElementById('clusterlane-ob-close');
+        if (closeBtn) closeBtn.addEventListener('click', function() {
+            overlay.style.display = 'none';
+            location.reload();
+        });
+
+        // "Explore clusters" also closes overlay (navigation handles the rest).
+        overlay.querySelector('#clusterlane-ob-step-5 .clusterlane-action-btn--primary')?.addEventListener('click', function() {
+            overlay.style.display = 'none';
+        });
+
+    })(); }); // end DOMContentLoaded + onboarding IIFE
+
+    /* ------------------------------------------------------------------
+       Site map (0.98.094) — every cluster as a rectangle in a squarified
+       treemap. Area = articles in the cluster, colour = health or orphan
+       share. Clicking a tile shows the cluster in the Cluster Graph below
+       (same handler as the old Needs Attention rows: .clusterlane-dash-cluster-row--graph).
+       No AI, no extra request: data comes from the shared GET /clusters.
+    ------------------------------------------------------------------ */
+    (function () {
+        var card    = document.getElementById( 'clusterlane-sm-card' );
+        var canvas  = document.getElementById( 'clusterlane-sm-canvas' );
+        var legend  = document.getElementById( 'clusterlane-sm-legend' );
+        var take    = document.getElementById( 'clusterlane-sm-takeaway' );
+        var modeSeg = document.getElementById( 'clusterlane-sm-mode' );
+        if ( ! card || ! canvas || typeof clusterlaneData === 'undefined' ) return;
+
+        var CI_URL    = clusterlaneData.clusterInspectorUrl || '';
+        var MODE_KEY  = 'clusterlane_sm_mode';
+        var OPEN_KEY  = 'clusterlane_sm_open';
+        var MAX_TILES = 48;   // beyond this, the smallest are grouped into one tile
+        var mode      = 'health';
+        var items     = [];   // { key, label, members, health, orphans, archetype }
+        var rest      = null; // { count, members } — grouped small clusters
+        var lastW     = 0;
+        var tipEl     = null;
+
+        try { if ( localStorage.getItem( MODE_KEY ) === 'orphans' ) mode = 'orphans'; } catch ( e ) {}
+        try { if ( localStorage.getItem( OPEN_KEY ) === 'false' ) card.open = false; } catch ( e ) {}
+        card.addEventListener( 'toggle', function () {
+            try { localStorage.setItem( OPEN_KEY, card.open ); } catch ( e ) {}
+            if ( card.open ) draw();
+        } );
+
+        function esc( str ) {
+            return String( str == null ? '' : str ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
+                .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' ).replace( /'/g, '&#039;' );
+        }
+        function decode( str ) {
+            var t = document.createElement( 'textarea' ); t.innerHTML = String( str || '' ); return t.value;
+        }
+        function ciHref( key ) { return CI_URL + ( CI_URL.indexOf( '?' ) !== -1 ? '&' : '?' ) + 'open=' + encodeURIComponent( key ); }
+        function plural( n, one, many ) { return n + ' ' + ( n === 1 ? one : many ); }
+
+        /* ── Colours ─────────────────────────────────────────────────── */
+        var HEALTH = {
+            good: { bg: '#dcfce7', edge: '#16a34a', text: '#14532d', label: 'Good (70%+)' },
+            fair: { bg: '#fef3c7', edge: '#d97706', text: '#78350f', label: 'Fair (50–69%)' },
+            weak: { bg: '#fee2e2', edge: '#dc2626', text: '#7f1d1d', label: 'Weak (under 50%)' }
+        };
+        var ORPH = [ // share of articles with no inbound links
+            { max: 0,        bg: '#f1f5f9', edge: '#94a3b8', text: '#334155', label: 'None' },
+            { max: 0.10,     bg: '#dbeafe', edge: '#60a5fa', text: '#1e3a8a', label: 'Under 10%' },
+            { max: 0.25,     bg: '#93c5fd', edge: '#2563eb', text: '#1e3a8a', label: '10–25%' },
+            { max: Infinity, bg: '#2563eb', edge: '#1e40af', text: '#ffffff', label: '25% or more' }
+        ];
+        function band( h ) { return h >= 70 ? 'good' : h >= 50 ? 'fair' : 'weak'; }
+        function orphStep( it ) {
+            var share = it.members ? it.orphans / it.members : 0;
+            for ( var i = 0; i < ORPH.length; i++ ) { if ( share <= ORPH[ i ].max ) return ORPH[ i ]; }
+            return ORPH[ ORPH.length - 1 ];
+        }
+        function colourOf( it ) { return mode === 'orphans' ? orphStep( it ) : HEALTH[ band( it.health ) ]; }
+
+        /* ── Squarified treemap (Bruls, Huizing & van Wijk) ──────────── */
+        function worst( row, len ) {
+            var s = 0, max = 0, min = Infinity;
+            row.forEach( function ( r ) { s += r.area; if ( r.area > max ) max = r.area; if ( r.area < min ) min = r.area; } );
+            return Math.max( ( len * len * max ) / ( s * s ), ( s * s ) / ( len * len * min ) );
+        }
+        function placeRow( row, r, out ) {
+            var s = 0; row.forEach( function ( it ) { s += it.area; } );
+            if ( r.w >= r.h ) {
+                var cw = s / r.h, y = r.y;
+                row.forEach( function ( it ) { var h = it.area / cw; out.push( { it: it, x: r.x, y: y, w: cw, h: h } ); y += h; } );
+                return { x: r.x + cw, y: r.y, w: r.w - cw, h: r.h };
+            }
+            var rh = s / r.w, x = r.x;
+            row.forEach( function ( it ) { var w = it.area / rh; out.push( { it: it, x: x, y: r.y, w: w, h: rh } ); x += w; } );
+            return { x: r.x, y: r.y + rh, w: r.w, h: r.h - rh };
+        }
+        function squarify( list, W, H ) {
+            var total = 0; list.forEach( function ( it ) { total += it.weight; } );
+            var scaled = list.map( function ( it ) { return { src: it, area: it.weight / total * W * H }; } );
+            var out = [], row = [], r = { x: 0, y: 0, w: W, h: H }, i = 0;
+            while ( i < scaled.length ) {
+                var len = Math.min( r.w, r.h );
+                if ( ! row.length || worst( row.concat( [ scaled[ i ] ] ), len ) <= worst( row, len ) ) { row.push( scaled[ i ] ); i++; }
+                else { r = placeRow( row, r, out ); row = []; }
+            }
+            if ( row.length ) placeRow( row, r, out );
+            return out;
+        }
+
+        /* ── Render ─────────────────────────────────────────────────── */
+        function currentKey() { try { return localStorage.getItem( 'clusterlane_dash_graph_key' ) || ''; } catch ( e ) { return ''; } }
+
+        function draw() {
+            if ( ! card.open ) return;
+            var W = canvas.clientWidth, H = canvas.clientHeight;
+            if ( ! W || ! H ) return;
+            lastW = W;
+            if ( ! items.length ) return;
+
+            var list = items.map( function ( it ) { return { weight: it.members, it: it }; } );
+            if ( rest ) list.push( { weight: rest.members, rest: rest } );
+            var cells = squarify( list, W, H );
+            var cur = currentKey();
+
+            canvas.innerHTML = cells.map( function ( c ) {
+                var pos = 'left:' + ( c.x / W * 100 ) + '%;top:' + ( c.y / H * 100 ) + '%;width:' + ( c.w / W * 100 ) + '%;height:' + ( c.h / H * 100 ) + '%;';
+                var src  = c.it.src; // squarify cell → { weight, it | rest }
+                var size = ( c.w < 58 || c.h < 30 ) ? 'xs' : ( c.w < 120 || c.h < 54 ) ? 'sm' : 'md';
+                if ( src.rest ) {
+                    return '<a class="clusterlane-sm-tile clusterlane-sm-tile--rest clusterlane-sm-tile--' + size + '" href="' + esc( CI_URL ) + '" style="' + pos + '"'
+                        + ' data-tip="' + esc( plural( rest.count, 'smaller cluster', 'smaller clusters' ) + ' · open the full list in Cluster Inspector' ) + '">'
+                        + ( size === 'xs' ? '' : '<span class="clusterlane-sm-name">+' + rest.count + ' more</span>' )
+                        + '</a>';
+                }
+                var it  = src.it, col = colourOf( it );
+                var meta = mode === 'orphans'
+                    ? plural( it.orphans, 'orphan', 'orphans' ) + ' of ' + it.members
+                    : plural( it.members, 'article', 'articles' ) + ' · ' + it.health + '%';
+                var tip = it.label + ' — ' + plural( it.members, 'article', 'articles' ) + ' · health ' + it.health + '% · '
+                    + plural( it.orphans, 'orphan', 'orphans' ) + ( it.archetype ? ' · ' + it.archetype : '' );
+                return '<a class="clusterlane-sm-tile clusterlane-dash-cluster-row--graph clusterlane-sm-tile--' + size + ( it.key === cur ? ' is-current' : '' ) + '"'
+                    + ' href="' + esc( ciHref( it.key ) ) + '" data-cluster-key="' + esc( it.key ) + '"'
+                    + ' style="' + pos + '--sm-bg:' + col.bg + ';--sm-edge:' + col.edge + ';--sm-text:' + col.text + ';"'
+                    + ' data-tip="' + esc( tip ) + '" aria-label="' + esc( tip ) + '">'
+                    + ( size === 'xs' ? '' : '<span class="clusterlane-sm-name clusterlane-dash-cluster-label">' + esc( it.label ) + '</span>' )
+                    + ( size === 'md' ? '<span class="clusterlane-sm-meta">' + esc( meta ) + '</span>' : '' )
+                    + '</a>';
+            } ).join( '' );
+            drawLegend();
+        }
+
+        function drawLegend() {
+            if ( legend ) {
+                var steps = mode === 'orphans' ? ORPH : [ HEALTH.good, HEALTH.fair, HEALTH.weak ];
+                legend.innerHTML = ( mode === 'orphans' ? '<span class="clusterlane-sm-legend-title">Articles without inbound links</span>' : '' )
+                    + steps.map( function ( s ) {
+                        return '<span class="clusterlane-sm-legend-item"><span class="clusterlane-sm-swatch" style="background:' + s.bg + ';border-color:' + s.edge + ';"></span>' + esc( s.label ) + '</span>';
+                    } ).join( '' ) + '<span class="clusterlane-sm-legend-note">Area = articles</span>';
+            }
+            if ( take ) {
+                // Plain-language takeaway (member counts: an article in two clusters counts twice).
+                var tot = 0, weak = 0, fair = 0, orph = 0;
+                items.forEach( function ( it ) {
+                    tot += it.members; orph += it.orphans;
+                    if ( it.health < 50 ) weak += it.members; else if ( it.health < 70 ) fair += it.members;
+                } );
+                if ( rest ) { tot += rest.members; weak += rest.weak; fair += rest.fair; orph += rest.orphans; }
+                var pct = function ( n ) { return tot ? Math.round( n / tot * 100 ) : 0; };
+                var msg;
+                if ( mode === 'orphans' ) {
+                    msg = orph ? pct( orph ) + '% of the articles in your clusters have no links pointing to them.'
+                               : 'Every clustered article has at least one link pointing to it.';
+                } else if ( weak ) {
+                    msg = 'Weak clusters hold ' + pct( weak ) + '% of the articles in your clusters.';
+                } else if ( fair ) {
+                    msg = 'No weak clusters. ' + pct( fair ) + '% of the articles sit in clusters that need attention.';
+                } else {
+                    msg = 'Every cluster is at 70% health or more.';
+                }
+                take.innerHTML = clusterlaneGlyph( 'target' ) + esc( msg );
+            }
+        }
+
+        function setMode( m ) {
+            mode = m === 'orphans' ? 'orphans' : 'health';
+            try { localStorage.setItem( MODE_KEY, mode ); } catch ( e ) {}
+            if ( modeSeg ) modeSeg.querySelectorAll( 'button[data-mode]' ).forEach( function ( b ) {
+                var on = b.dataset.mode === mode;
+                b.classList.toggle( 'is-active', on );
+                b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
+            } );
+            draw();
+        }
+
+        // Buttons live in <summary>: stop the click from folding the card.
+        if ( modeSeg ) modeSeg.addEventListener( 'click', function ( e ) {
+            var b = e.target.closest( 'button[data-mode]' );
+            e.preventDefault(); e.stopPropagation();
+            if ( b && b.dataset.mode !== mode ) setMode( b.dataset.mode );
+        } );
+
+        /* ── Current cluster + tooltip ─────────────────────────────── */
+        function markCurrent( key ) {
+            canvas.querySelectorAll( '.clusterlane-sm-tile' ).forEach( function ( t ) {
+                t.classList.toggle( 'is-current', !! key && t.dataset.clusterKey === key );
+            } );
+        }
+        canvas.addEventListener( 'click', function ( e ) {
+            var t = e.target.closest( '.clusterlane-sm-tile[data-cluster-key]' );
+            if ( t && ! ( e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ) ) markCurrent( t.dataset.clusterKey );
+        } );
+        var sel = document.getElementById( 'clusterlane-dash-graph-select' );
+        if ( sel ) sel.addEventListener( 'change', function () { markCurrent( sel.value ); } );
+
+        function hideTip() { if ( tipEl ) tipEl.style.display = 'none'; }
+        canvas.addEventListener( 'mousemove', function ( e ) {
+            var t = e.target.closest( '.clusterlane-sm-tile' );
+            if ( ! t ) { hideTip(); return; }
+            if ( ! tipEl ) { tipEl = document.createElement( 'div' ); tipEl.className = 'clusterlane-sm-tip'; tipEl.setAttribute( 'aria-hidden', 'true' ); canvas.parentNode.appendChild( tipEl ); }
+            tipEl.textContent = t.getAttribute( 'data-tip' ) || '';
+            tipEl.style.display = 'block';
+            var box = canvas.parentNode.getBoundingClientRect();
+            var x = e.clientX - box.left + 14, y = e.clientY - box.top + 16;
+            var maxX = box.width - tipEl.offsetWidth - 4;
+            tipEl.style.left = Math.max( 4, Math.min( x, maxX ) ) + 'px';
+            tipEl.style.top  = y + 'px';
+        } );
+        canvas.addEventListener( 'mouseleave', hideTip );
+
+        // Re-layout when the width changes (the aspect ratio decides the rows).
+        var resizeTimer = null;
+        function onResize() {
+            clearTimeout( resizeTimer );
+            resizeTimer = setTimeout( function () { if ( Math.abs( canvas.clientWidth - lastW ) > 8 ) draw(); }, 120 );
+        }
+        if ( typeof ResizeObserver !== 'undefined' ) new ResizeObserver( onResize ).observe( canvas );
+        else window.addEventListener( 'resize', onResize );
+
+        /* ── Data ───────────────────────────────────────────────────── */
+        setMode( mode );
+        clusterlaneDashClustersLoad().then( function ( d ) {
+            var all = ( ( d && d.clusters ) || [] ).map( function ( c ) {
+                return {
+                    key: c.cluster_key, label: decode( c.cluster_label ), members: parseInt( c.total_members, 10 ) || 0,
+                    health: parseInt( c.health_score, 10 ) || 0, orphans: parseInt( c.orphans, 10 ) || 0,
+                    archetype: c.archetype || ''
+                };
+            } ).filter( function ( c ) { return c.key && c.members > 0; } )
+              .sort( function ( a, b ) { return b.members - a.members || a.health - b.health; } );
+
+            if ( ! all.length ) {
+                canvas.innerHTML = '<p class="clusterlane-sm-empty">Your clusters appear here after the first scan.</p>';
+                if ( legend ) legend.innerHTML = '';
+                if ( take ) take.textContent = '';
+                return;
+            }
+            items = all.slice( 0, all.length > MAX_TILES ? MAX_TILES - 1 : MAX_TILES );
+            var small = all.slice( items.length );
+            rest = small.length ? small.reduce( function ( acc, c ) {
+                acc.count++; acc.members += c.members; acc.orphans += c.orphans;
+                if ( c.health < 50 ) acc.weak += c.members; else if ( c.health < 70 ) acc.fair += c.members;
+                return acc;
+            }, { count: 0, members: 0, orphans: 0, weak: 0, fair: 0 } ) : null;
+            draw();
+        } ).catch( function () {
+            canvas.innerHTML = '<p class="clusterlane-sm-empty">Could not load clusters.</p>';
+        } );
+    } )();
+
+    /* ------------------------------------------------------------------
+       Dashboard Graph Widget
+       Full-width cluster graph with dropdown cluster switcher.
+    ------------------------------------------------------------------ */
+    (function () {
+        if ( typeof clusterlaneData === 'undefined' || typeof vis === 'undefined' ) return;
+
+        var REST  = clusterlaneData.restUrl;
+        var NONCE = clusterlaneData.nonce;
+        var CI_URL = clusterlaneData.clusterInspectorUrl || '';
+
+        var select    = document.getElementById( 'clusterlane-dash-graph-select' );
+        var container = document.getElementById( 'clusterlane-dash-graph-container' );
+        var notice    = document.getElementById( 'clusterlane-dash-graph-notice' );
+        var ciLink    = document.getElementById( 'clusterlane-dash-graph-ci-link' );
+
+        if ( ! select || ! container ) return;
+
+        var widgetNetwork = null;
+        var widgetResizeObserver = null;
+        var currentKey   = '';
+        var clusterMap   = {};   // key → cluster object from /clusters response
+        var infoEl       = document.getElementById( 'clusterlane-dash-graph-info' );
+        var titleEl      = document.getElementById( 'clusterlane-dash-graph-title' );
+        var STORAGE_KEY  = 'clusterlane_dash_graph_key';
+
+        var ROLE_COLORS = {
+            pillar:  { bg: '#fde8d4', border: '#e76c2e' },
+            support: { bg: '#fef9c3', border: '#f5c842' },
+            light:   { bg: '#e8f5d9', border: '#90b865' },
+            orphan:  { bg: '#dbeafe', border: '#5ba4cf' },
+            hub:     { bg: '#f1f5f9', border: '#94a3b8' },
+        };
+
+        function esc( str ) {
+            return String( str )
+                .replace( /&/g, '&amp;' ).replace( /</g, '&lt;' )
+                .replace( />/g, '&gt;' ).replace( /"/g, '&quot;' )
+                .replace( /'/g, '&#039;' );
+        }
+
+        function buildNodeTooltip( n ) {
+            var col      = ROLE_COLORS[ n.class ] || ROLE_COLORS.light;
+            var label    = truncTitle( n.title && typeof n.title === 'string' ? n.title.split( '\n' )[ 0 ] : ( n.label || '' ) );
+            var role     = n.class || 'light';
+            var dotColor = col.border;
+
+            // Read inbound + score from the server-supplied plain-text title string
+            // ("Post Title\nInbound: 9 | Score: 94") which is always present and
+            // reliable, rather than from n.inbound which may be absent in some
+            // graph responses.
+            var titleStr = ( n.title && typeof n.title === 'string' ) ? n.title : '';
+            var inbound  = n.inbound != null ? parseInt( n.inbound, 10 ) : 0;
+            var imatch   = titleStr.match( /Inbound:\s*(\d+)/ );
+            if ( imatch ) inbound = parseInt( imatch[ 1 ], 10 );
+
+            var score  = null;
+            var smatch = titleStr.match( /Score:\s*(\d+)/ );
+            if ( smatch ) score = parseInt( smatch[ 1 ], 10 );
+            var scoreHtml = score !== null
+                ? '<span style="margin-left:8px;color:' + ( score >= 80 ? '#16a34a' : score >= 60 ? '#ca8a04' : '#dc2626' ) + ';font-weight:600;">' + score + '%</span>'
+                : '';
+
+            var isHub     = role === 'hub';
+            var statsHtml = isHub
+                ? '<div style="margin-top:5px;font-size:10px;color:#94a3b8;">' + ( n.title ? n.title.split( '\n' ).slice( 1 ).join( ' · ' ) : '' ) + '</div>'
+                : '<div style="margin-top:6px;display:flex;align-items:center;gap:4px;font-size:10px;color:#94a3b8;">'
+                  + '<span style="display:inline-flex;align-items:center;gap:3px;">'
+                  + '<span style="width:7px;height:7px;border-radius:50%;background:' + dotColor + ';flex-shrink:0;"></span>'
+                  + '<span style="text-transform:capitalize;color:' + dotColor + ';font-weight:600;">' + role + '</span>'
+                  + '</span>'
+                  + '<span style="color:#cbd5e1;">·</span>'
+                  + '<span>Inbound: <strong style="color:#f1f5f9;">' + inbound + '</strong></span>'
+                  + ( scoreHtml ? '<span style="color:#cbd5e1;">·</span><span>Score:' + scoreHtml + '</span>' : '' )
+                  + '</div>';
+
+            var el = document.createElement( 'div' );
+            el.style.cssText = [
+                'background:#1e2535', 'color:#e2e8f0', 'border-radius:7px',
+                'padding:8px 11px', 'min-width:180px', 'max-width:320px',
+                'box-shadow:0 4px 14px rgba(0,0,0,.35)',
+                'font-family:system-ui,sans-serif',
+                'pointer-events:none', 'line-height:1.4',
+            ].join( ';' );
+            el.innerHTML = ( n.thumb && n.class !== 'hub' && typeof window.clusterlaneThumbsOn === 'function' && window.clusterlaneThumbsOn() ? '<img class="clusterlane-graph-tip-img" src="' + esc( n.thumb ) + '" alt="">' : '' ) + '<div style="font-size:12px;font-weight:600;color:#f1f5f9;word-break:break-word;">' + esc( label ) + '</div>' + statsHtml;
+            return el;
+        }
+
+        // Graph labels (0.98.187): the title display rule, then at most ~24 characters cut at a
+        // word ("Vasa Museum: The Warship…"), never mid-word ("…The Warship Tha…").
+        function graphLabel( n ) {
+            // The server cuts long titles at 28 characters; only then is the full title (first line of the tooltip) needed.
+        const lbl = String( n.label || '' );
+        const full = /…$/.test( lbl ) && typeof n.title === 'string' ? n.title.split( '\n' )[ 0 ] : lbl;
+            const t = truncTitle( full );
+            if ( t.length <= 24 ) return t;
+            const cut = t.slice( 0, 24 ), sp = cut.lastIndexOf( ' ' );
+            // A lone short word after a colon says nothing ("Hallwyl Museum: A…") — stop at the colon.
+        return ( sp > 10 ? cut.slice( 0, sp ) : cut ).replace( /:\s*\S{1,3}$/, '' ).replace( /[\s:,–—-]+$/, '' ) + '…';
+        }
+
+        function truncTitle( t ) {
+            if ( ! t ) return '';
+            var mode = ( clusterlaneData.titleTrunc || 'none' );
+            if ( mode === 'none' ) return t;
+            var decoded = t.replace( /&amp;/g,'&' ).replace( /&lt;/g,'<' ).replace( /&gt;/g,'>' ).replace( /&quot;/g,'"' ).replace( /&#039;/g,"'" );
+            var part = decoded;
+            if ( mode === 'colon' )       part = decoded.replace( /\s*:.*$/, '' ).trim();
+            else if ( mode === 'dash' )   part = decoded.replace( /(\s*[\u2013\u2014]|\s+-\s).*$/, '' ).trim();
+            else if ( mode === 'pipe' )   part = decoded.replace( /\s*\|.*$/, '' ).trim();
+            else if ( mode === 'custom' && clusterlaneData.titleTruncCustom ) {
+                var m = clusterlaneData.titleTruncCustom.match( /^\/(.+)\/([gimsuy]*)$/ );
+                if ( m ) { try { part = decoded.replace( new RegExp( m[1], m[2] ), '' ).trim(); } catch(e){} }
+            }
+            part = part || decoded;
+            return part.length > 48 ? part.slice( 0, 45 ) + '…' : part;
+        }
+
+        function showNotice( msg ) {
+            notice.textContent = msg;
+            notice.style.display = 'block';
+            container.style.display = 'none';
+        }
+
+        function hideNotice() {
+            notice.style.display = 'none';
+            container.style.display = '';
+        }
+
+        function setOverlay( msg ) {
+            container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:13px;">' + msg + '</div>';
+        }
+
+        // ---------- Fetch cluster list ----------
+        function loadClusterList() {
+            clusterlaneDashClustersLoad()
+                .then( function( data ) {
+                    var clusters = ( Array.isArray( data ) ? data : ( data.clusters || [] ) ).slice();
+                    if ( ! clusters.length ) {
+                        select.innerHTML = '<option value="">— No clusters yet —</option>';
+                        showNotice( clusterlaneCanManage()
+                            ? 'Complete onboarding to see your cluster graph here.'
+                            : 'No clusters yet — ask an administrator to complete the ' + window.clusterlaneBrandName() + ' setup.' );
+                        return;
+                    }
+                    // Weakest cluster first — the graph shown on load should be
+                    // the one that most needs attention, not the first alphabetically.
+                    clusters.sort( function( a, b ) {
+                        var ha = a.health_score == null ? 999 : parseInt( a.health_score, 10 );
+                        var hb = b.health_score == null ? 999 : parseInt( b.health_score, 10 );
+                        return ha - hb;
+                    } );
+                    select.innerHTML = '';
+                    clusterMap = {};
+                    clusters.forEach( function( c ) {
+                        var k = c.cluster_key || c.key || '';
+                        if ( k ) clusterMap[ k ] = c;
+                        var opt = document.createElement( 'option' );
+                        opt.value = k;
+                        opt.textContent = c.cluster_label || c.label || opt.value;
+                        if ( c.health_score != null ) opt.textContent += '  (' + c.health_score + '%)';
+                        select.appendChild( opt );
+                    } );
+                    if ( select.options.length ) {
+                        // Restore last selected cluster from localStorage; fall back to
+                        // the weakest (first in the sorted list) if not found.
+                        var saved = '';
+                        try { saved = localStorage.getItem( STORAGE_KEY ) || ''; } catch(e) {}
+                        var restoredIdx = -1;
+                        if ( saved ) {
+                            for ( var i = 0; i < select.options.length; i++ ) {
+                                if ( select.options[i].value === saved ) { restoredIdx = i; break; }
+                            }
+                        }
+                        select.selectedIndex = restoredIdx !== -1 ? restoredIdx : 0;
+                        loadGraph( select.value );
+                    }
+                } )
+                .catch( function() {
+                    select.innerHTML = '<option value="">— Could not load clusters —</option>';
+                    showNotice( 'Could not load cluster list.' );
+                } );
+        }
+
+        // ---------- Render graph ----------
+        function renderWidgetGraph( data ) {
+            if ( widgetNetwork ) { widgetNetwork.destroy(); widgetNetwork = null; }
+            if ( widgetResizeObserver ) { widgetResizeObserver.disconnect(); widgetResizeObserver = null; }
+
+            var rawNodes = JSON.parse( JSON.stringify( data.nodes || [] ) ); // deep clone avoids HTMLElement clash
+            var rawEdges = data.edges || [];
+
+            var byGroup = { pillar:[], support:[], light:[], orphan:[] };
+            var hubNode = null;
+            rawNodes.forEach( function(n) {
+                if ( n.class === 'hub' ) { hubNode = n; return; }
+                var g = n.class || 'light';
+                if ( !byGroup[g] ) g = 'light';
+                byGroup[g].push(n);
+            } );
+
+            var layoutCfg = {
+                pillar:  { r: Math.max( 100, byGroup.pillar.length  * 40 ) },
+                support: { r: Math.max( 180, byGroup.support.length * 28 ) },
+                light:   { r: Math.max( 260, byGroup.light.length   * 22 ) },
+                orphan:  { r: Math.max( 340, byGroup.orphan.length  * 20 ) },
+            };
+
+            var startAngle = -Math.PI / 2;
+            ['pillar','support','light','orphan'].forEach( function(type) {
+                var group = byGroup[type];
+                var r = layoutCfg[type].r;
+                group.forEach( function(n, i) {
+                    var angle = startAngle + (2 * Math.PI * i) / Math.max(1, group.length);
+                    n.x = Math.cos(angle) * r;
+                    n.y = Math.sin(angle) * r * 0.82;
+                } );
+            } );
+
+            if ( hubNode ) { hubNode.x = 0; hubNode.y = 0; hubNode.fixed = { x:true, y:true }; }
+
+            rawNodes.forEach( function(n) {
+                var col = ROLE_COLORS[ n.class ] || ROLE_COLORS.light;
+                n.color = { background: col.bg, border: col.border, highlight: { background: col.bg, border: '#1e293b' } };
+                n.font  = { size: 11, color: '#1e293b', face: 'system-ui,sans-serif', vadjust: -22 };
+                n.shape = 'dot';
+                n.size  = n.class === 'hub' ? 20 : 13;
+                n.borderWidth = n.class === 'hub' ? 3 : 1.5;
+                if ( n.label ) n.label = graphLabel( n );
+
+                // Styled HTML tooltip — built after the JSON clone so
+                // HTMLElement references are never passed through structuredClone.
+                n.title = buildNodeTooltip( n );
+            } );
+
+            container.innerHTML = '';
+            var nodes = new vis.DataSet( rawNodes );
+            var edges = new vis.DataSet( rawEdges );
+
+            widgetNetwork = new vis.Network( container, { nodes:nodes, edges:edges }, {
+                layout:      { improvedLayout: false, randomSeed: 7 }, // same picture on every load (0.98.182)
+                nodes:       { shape:'dot', size:13, borderWidth:1.5, font:{ size:11, color:'#333', vadjust:-22, face:'system-ui,sans-serif' } },
+                edges:       { arrows:{ to:{ enabled:true, scaleFactor:0.7 } }, smooth:{ type:'curvedCW', roundness:0.22 }, width:1.2, color:'#b0b7c3' },
+                physics:     { enabled:true, minVelocity:0.75, stabilization:{ iterations:160 },
+                               barnesHut:{ gravitationalConstant:-280, centralGravity:0.12, springLength:160, springConstant:0.04, damping:0.72, avoidOverlap:1.0 } },
+                // zoomView starts OFF so the mouse wheel scrolls the page
+                // past the graph instead of getting captured by it. A click
+                // inside the graph turns zoom on; leaving turns it off again.
+                interaction: { hover:true, dragNodes:true, dragView:true, zoomView:false, tooltipDelay:80 },
+            } );
+
+            var hint = document.createElement( 'div' );
+            hint.className = 'clusterlane-dash-graph-hint';
+            hint.textContent = 'Click to enable zoom · drag to pan';
+            container.appendChild( hint );
+
+            var ringCfg = [
+                { r: layoutCfg.pillar.r,  fill: 'rgba(231,108,46,0.06)' },
+                { r: layoutCfg.support.r, fill: 'rgba(245,200,66,0.05)' },
+                { r: layoutCfg.light.r,   fill: 'rgba(144,184,101,0.04)' },
+            ];
+            widgetNetwork.on( 'beforeDrawing', function(ctx) {
+                ringCfg.forEach( function(ring) {
+                    ctx.beginPath(); ctx.arc( 0, 0, ring.r, 0, Math.PI*2 );
+                    ctx.fillStyle = ring.fill; ctx.fill();
+                } );
+            } );
+
+            widgetNetwork.once( 'stabilizationIterationsDone', function() {
+                widgetNetwork.setOptions( { physics: { enabled:false } } );
+                widgetNetwork.fit( { animation: { duration:300, easingFunction:'easeInOutQuad' } } );
+            } );
+
+            // Wheel-zoom only while the graph is "active" (clicked into).
+            var zoomOn = null;
+            function setZoom( on ) {
+                if ( ! widgetNetwork || zoomOn === on ) return;   // no change → no vis redraw
+                zoomOn = on;
+                widgetNetwork.setOptions( { interaction: { zoomView: on } } );
+                container.classList.toggle( 'is-zoom-active', on );
+            }
+            container.addEventListener( 'mousedown', function() { setZoom( true ); } );
+            container.addEventListener( 'mouseleave', function() { setZoom( false ); } );
+            // Release when the user clicks anywhere else — AFTER that click, so a
+            // graph redraw can never move content under the pointer mid-click.
+            document.addEventListener( 'click', function( e ) {
+                if ( ! container.contains( e.target ) ) setTimeout( function() { setZoom( false ); }, 0 );
+            } );
+
+            // Re-fit on container resize
+            var resizeDebounce = null;
+            widgetResizeObserver = new ResizeObserver( function() {
+                clearTimeout( resizeDebounce );
+                resizeDebounce = setTimeout( function() {
+                    if ( widgetNetwork ) { widgetNetwork.redraw(); widgetNetwork.fit(); }
+                }, 60 );
+            } );
+            widgetResizeObserver.observe( container );
+        }
+
+        // ---------- Info card ----------
+        function renderInfoCard( key, nodes ) {
+            if ( ! infoEl ) return;
+
+            var c = clusterMap[ key ];
+            if ( ! c ) { infoEl.style.display = 'none'; return; }
+
+            // Count roles from the graph nodes we already have.
+            var roles = { pillar: 0, support: 0, light: 0, orphan: 0 };
+            ( nodes || [] ).forEach( function( n ) {
+                if ( n.class && roles[ n.class ] !== undefined ) roles[ n.class ]++;
+            } );
+
+            var health    = parseInt( c.health_score, 10 ) || 0;
+            var archetype = c.archetype || '';
+            var members   = parseInt( c.total_members, 10 ) || 0;
+            var computed  = c.last_computed ? c.last_computed.split(' ')[0] : '';
+
+            // Health colour — mirrors the badge logic used elsewhere in the UI.
+            var hColor = health >= 70 ? '#16a34a' : health >= 40 ? '#d97706' : '#dc2626';
+            var hBg    = health >= 70 ? '#f0fdf4' : health >= 40 ? '#fffbeb' : '#fef2f2';
+
+            var ROLE_LABELS = {
+                pillar:  { label: 'Pillar',  color: '#e76c2e' },
+                support: { label: 'Support', color: '#f5c842' },
+                light:   { label: 'Light',   color: '#90b865' },
+                orphan:  { label: 'Orphan',  color: '#5ba4cf' },
+            };
+
+            var pillsHtml = Object.keys( ROLE_LABELS ).map( function( r ) {
+                var cnt = roles[ r ];
+                if ( ! cnt ) return '';
+                var cfg = ROLE_LABELS[ r ];
+                return '<span style="display:inline-flex;align-items:center;gap:4px;'
+                     + 'background:#f8fafc;border:1px solid #e2e8f0;border-radius:20px;'
+                     + 'padding:2px 9px;font-size:11px;color:#1e293b;">'
+                     + '<span style="width:8px;height:8px;border-radius:50%;background:' + cfg.color + ';flex-shrink:0;"></span>'
+                     + cfg.label + ' <strong>' + cnt + '</strong></span>';
+            } ).join( '' );
+
+            var arcHtml = archetype
+                ? '<span style="font-size:11px;color:#64748b;background:#f1f5f9;'
+                  + 'border-radius:4px;padding:2px 8px;">' + archetype + '</span>'
+                : '';
+
+            var computedHtml = computed
+                ? '<span style="font-size:11px;color:#94a3b8;">Updated ' + computed + '</span>'
+                : '';
+
+            infoEl.innerHTML =
+                '<div style="display:flex;flex-wrap:wrap;align-items:center;gap:12px 20px;'
+              + 'padding:12px 16px;border-top:1px solid #f1f5f9;">'
+              // Health badge
+              + '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">'
+              + '<span style="font-size:22px;font-weight:700;color:' + hColor + ';'
+              + 'background:' + hBg + ';border-radius:8px;padding:2px 10px;line-height:1.3;">'
+              + health + '%</span>'
+              + '<span style="font-size:11px;color:#64748b;line-height:1.4;">'
+              + members + ' articles</span>'
+              + clusterlaneTipHtml( 'health' )
+              + '</div>'
+              // Role pills
+              + ( pillsHtml ? '<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;">' + pillsHtml + clusterlaneTipHtml( 'role' ) + '</div>' : '' )
+              // Archetype + date
+              + '<div style="display:flex;align-items:center;gap:8px;margin-left:auto;flex-wrap:wrap;">'
+              + arcHtml + ( archetype ? clusterlaneTipHtml( 'archetype' ) : '' ) + computedHtml
+              + '</div>'
+              + '</div>';
+
+            infoEl.style.display = '';
+        }
+
+        // ---------- Fetch + render ----------
+        function loadGraph( key ) {
+            if ( ! key ) return;
+            currentKey = key;
+
+            // Persist selection so the same cluster is shown on next page load.
+            try { localStorage.setItem( STORAGE_KEY, key ); } catch(e) {}
+
+            // Update heading with cluster name (strip the health% suffix if present).
+            if ( titleEl ) {
+                var c = clusterMap[ key ];
+                var label = c ? ( c.cluster_label || c.label || '' ) : '';
+                titleEl.textContent = label ? 'Cluster Graph: ' + label : 'Cluster Graph';
+            }
+
+            // Update "Open in Inspector" link
+            if ( ciLink ) {
+                ciLink.href = CI_URL + ( CI_URL.indexOf('?') !== -1 ? '&' : '?' ) + 'open=' + encodeURIComponent( key );
+                ciLink.style.display = '';
+            }
+
+            setOverlay( 'Loading graph…' );
+            hideNotice();
+            if ( infoEl ) infoEl.style.display = 'none';
+
+            var xhr = new XMLHttpRequest();
+            xhr.open( 'POST', REST + 'graph/' + encodeURIComponent( key ) );
+            xhr.setRequestHeader( 'X-WP-Nonce', NONCE );
+            xhr.setRequestHeader( 'Content-Type', 'application/json' );
+            xhr.onload = function() {
+                var data;
+                try { data = JSON.parse( xhr.responseText ); } catch(e) { data = null; }
+                if ( ! data || ! data.nodes || ! data.nodes.length ) {
+                    setOverlay( 'No graph data for this cluster — click Scan in the bar at the top first.' );
+                    if ( infoEl ) infoEl.style.display = 'none';
+                    return;
+                }
+                if ( window.clusterlaneGraphNote ) window.clusterlaneGraphNote( container, data ); // big clusters: only part is drawn (0.98.169)
+                renderWidgetGraph( data );
+                renderInfoCard( key, data.nodes );
+            };
+            xhr.onerror = function() { setOverlay( 'Network error loading graph.' ); };
+            xhr.send( '{}' );
+        }
+
+        // ---------- Dropdown change ----------
+        select.addEventListener( 'change', function() {
+            if ( this.value ) loadGraph( this.value );
+        } );
+
+        // ---------- Needs Attention → graph ----------
+        // Clicking a cluster row in Needs Attention shows it in the graph
+        // instead of navigating away. The href stays as a fallback
+        // (and for middle-click / open in new tab).
+        document.addEventListener( 'click', function( e ) {
+            var row = e.target.closest( '.clusterlane-dash-cluster-row--graph' );
+            if ( ! row ) return;
+            if ( e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ) return;
+            var key = row.getAttribute( 'data-cluster-key' );
+            if ( ! key ) return;
+            e.preventDefault();
+
+            // Select in dropdown if present (it may be absent if the cluster
+            // list failed to load — loadGraph still works with the key).
+            var found = false;
+            for ( var i = 0; i < select.options.length; i++ ) {
+                if ( select.options[i].value === key ) { select.selectedIndex = i; found = true; break; }
+            }
+            if ( ! found ) {
+                var opt = document.createElement( 'option' );
+                opt.value = key; opt.textContent = row.querySelector( '.clusterlane-dash-cluster-label' )?.textContent || key;
+                select.appendChild( opt ); select.value = key;
+            }
+
+            loadGraph( key );
+
+            var card = document.getElementById( 'clusterlane-dash-graph-card' );
+            if ( card ) {
+                if ( card.tagName === 'DETAILS' && ! card.open ) card.open = true;
+                card.scrollIntoView( { behavior: 'smooth', block: 'start' } );
+                card.classList.remove( 'clusterlane-dash-graph-card--flash' );
+                void card.offsetWidth; // restart animation
+                card.classList.add( 'clusterlane-dash-graph-card--flash' );
+            }
+        } );
+
+        // ---------- Boot ----------
+        document.addEventListener( 'DOMContentLoaded', function() {
+            loadClusterList();
+            // Persist graph open/closed state
+            var graphCard = document.getElementById('clusterlane-dash-graph-card');
+            if (graphCard) {
+                var saved = localStorage.getItem('clusterlane_graph_open');
+                if (saved === 'false') graphCard.open = false;
+                graphCard.addEventListener('toggle', function() {
+                    try { localStorage.setItem('clusterlane_graph_open', graphCard.open); } catch(e) {}
+                });
+            }
+        } );
+        // If DOMContentLoaded already fired (script in footer)
+        if ( document.readyState === 'complete' || document.readyState === 'interactive' ) {
+            loadClusterList();
+        }
+
+    } )();
+
+    })();
Index: /clusterlane/tags/1.3.2/admin/js/report.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/report.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/report.js	(revision 3751027)
@@ -0,0 +1,39 @@
+/* Clusterlane — Report page (0.98.125).
+ *
+ * The page is rendered on the server; this only binds the Print button,
+ * "Show all clusters" and, in Pro, the AI summary (window.clusterlanePro.reportSummary
+ * from admin/js/pro__premium_only/clusterlane-pro.js). Free shows a labelled example
+ * rendered in PHP, so there is nothing to do here for it.
+ */
+( function () {
+    'use strict';
+    var R = window.clusterlaneReport || { labels: {} };
+
+    document.querySelectorAll( '[data-clusterlane-print]' ).forEach( function ( b ) {
+        b.addEventListener( 'click', function () { window.print(); } );
+    } );
+
+    var more = document.querySelector( '[data-clusterlane-more]' );
+    var rest = document.querySelector( '.clusterlane-rp-more' );
+    if ( more && rest ) {
+        var showAll = more.textContent.trim();
+        more.addEventListener( 'click', function () {
+            var open = rest.hasAttribute( 'hidden' );
+            if ( open ) rest.removeAttribute( 'hidden' ); else rest.setAttribute( 'hidden', '' );
+            more.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
+            more.textContent = open ? ( R.labels.less || 'Show fewer clusters' ) : showAll;
+        } );
+    }
+
+    var card = document.getElementById( 'clusterlane-rp-summary' );
+    if ( card && typeof clusterlaneProReady === 'function' && clusterlaneProReady() && window.clusterlanePro && window.clusterlanePro.reportSummary ) {
+        window.clusterlanePro.reportSummary( {
+            card:   card,
+            body:   card.querySelector( '[data-clusterlane-summary-body]' ),
+            btn:    card.querySelector( '[data-clusterlane-summary-run]' ),
+            meta:   card.querySelector( '[data-clusterlane-summary-meta]' ),
+            period: parseInt( card.dataset.period, 10 ) || 84,
+            cfg:    R,
+        } );
+    }
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/seo-titles.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/seo-titles.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/seo-titles.js	(revision 3751027)
@@ -0,0 +1,850 @@
+/**
+ * SEO Titles — admin/js/seo-titles.js (0.98.154, free)
+ *
+ * Every article's SEO title and meta description in one list. Edit by hand
+ * and Apply (free), or select articles and let AI suggest (Pro: the calls go
+ * through window.clusterlanePro.seoSuggest, one article per call, like the search
+ * preview in Article Inspector). Each field is chosen on its own: keep the
+ * current text, use the AI's, or edit either before applying.
+ *
+ * Data:  GET  inspector/articles?seo=1&sort=meta  (+ cached AI per row in Pro)
+ * Write: POST seo/apply, seo/undo (RestSeo) — custom fields only; with an SEO
+ *        plugin (or without the Write SEO right) the text is copy-only.
+ *
+ * Depends on: clusterlaneData (restUrl, nonce, titleTrunc, articleInspectorUrl) from
+ * clusterlane-admin, clusterlaneGlyph/clusterlaneThumb/clusterlaneTip (clusterlane-tips.js), clusterlaneProReady/clusterlaneProBanner/
+ * clusterlaneProExample/clusterlaneFreezeExample (clusterlane-edition.js).
+ */
+( function () {
+    'use strict';
+
+    const root = document.getElementById( 'clusterlane-seot' );
+    if ( ! root || typeof clusterlaneData === 'undefined' ) return;
+
+    const REST  = clusterlaneData.restUrl;
+    const NONCE = clusterlaneData.nonce;
+    const $ = id => document.getElementById( id );
+    const el = {
+        rows: $( 'clusterlane-seot-rows' ), pager: $( 'clusterlane-seot-pager' ), summary: $( 'clusterlane-seot-summary' ),
+        note: $( 'clusterlane-seot-note' ), q: $( 'clusterlane-seot-q' ), cluster: $( 'clusterlane-seot-cluster' ), meta: $( 'clusterlane-seot-meta' ),
+        all: $( 'clusterlane-seot-all' ), selected: $( 'clusterlane-seot-selected' ), suggest: $( 'clusterlane-seot-suggest' ),
+        applyAll: $( 'clusterlane-seot-apply-all' ), run: $( 'clusterlane-seot-run' ), example: $( 'clusterlane-seot-example' ),
+        check: $( 'clusterlane-seot-check' ),
+    };
+
+    const glyph = n => ( typeof window.clusterlaneGlyph === 'function' ? window.clusterlaneGlyph( n ) : '' );
+
+    // Compact view (0.98.187): one line per article; Edit opens the full card. A row
+    // with something going on (a change, AI suggestions, an error, Undo) is always open.
+    let view = 'compact';
+    try { if ( localStorage.getItem( 'clusterlane-seot-view' ) === 'full' ) view = 'full'; } catch ( e ) {}
+    // r.open: true = opened with Edit, false = closed with Collapse (wins over the automatic
+    // opening, 0.98.189), undefined = open only while something is going on.
+    const isOpen = r => view === 'full' || r.busy || r.open === true
+        || ( r.open !== false && ( changed( r ) || !! r.ai || !! r.status || !! r.aiErr || r.undoUntil !== 0 ) );
+    const thumb = ( it, size ) => ( typeof window.clusterlaneThumb === 'function' ? window.clusterlaneThumb( it.thumb || '', it.title, it.role || '', size || 'md' ) : '' );
+    const proReady = () => typeof window.clusterlaneProReady === 'function' && window.clusterlaneProReady();
+    const chars = s => [ ...( s || '' ) ].length;
+    const clean = s => String( s || '' ).replace( /\s+/g, ' ' ).trim();
+
+    function esc( s ) {
+        return String( s == null ? '' : s ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
+    }
+
+    // Short title (Settings → Title Display), same rules as the other pages. Display only.
+    function shortTitle( t ) {
+        const d = String( t || '' ).replace( /&amp;/g, '&' ).replace( /&quot;/g, '"' ).replace( /&#0?39;/g, "'" );
+        const mode = clusterlaneData.titleTrunc || 'none';
+        let cut = d;
+        if ( mode === 'colon' ) cut = d.replace( /\s*:.*$/, '' ).trim();
+        else if ( mode === 'dash' ) cut = d.replace( /(\s*[–—]|\s+-\s).*$/, '' ).trim();
+        else if ( mode === 'pipe' ) cut = d.replace( /\s*\|.*$/, '' ).trim();
+        else if ( mode === 'custom' && clusterlaneData.titleTruncCustom ) {
+            try { const m = clusterlaneData.titleTruncCustom.match( /^\/(.+)\/([gimsuy]*)$/ ); if ( m ) cut = d.replace( new RegExp( m[1], m[2] ), '' ).trim(); } catch ( e ) {}
+        }
+        return cut || d;
+    }
+
+    // Errors carry the HTTP status, so the AI run can stop at a limit.
+    async function apiFetch( path, opts = {} ) {
+        const res = await fetch( REST + path, { ...opts, headers: { 'X-WP-Nonce': NONCE, 'Content-Type': 'application/json', ...( opts.headers || {} ) } } );
+        const data = await res.json().catch( () => ( {} ) );
+        if ( ! res.ok ) {
+            const err = new Error( ( data && ( data.error || data.message ) ) || ( 'HTTP ' + res.status ) );
+            err.status = res.status;
+            throw err;
+        }
+        return data;
+    }
+    const post = ( path, body ) => apiFetch( path, { method: 'POST', body: JSON.stringify( body ) } );
+
+    /* ------------------------------------------------------------------
+       State
+    ------------------------------------------------------------------ */
+    const params = new URLSearchParams( location.search );
+    const st = {
+        q: params.get( 'q' ) || '', cluster: params.get( 'cluster' ) || '', lang: '',
+        meta: [ 'missing', 'weak', 'title', 'dup', 'ai' ].includes( params.get( 'meta' ) ) ? params.get( 'meta' ) : '',
+        page: 1, req: 0, ctx: null, rows: [], byId: {}, sel: new Set(), running: null, batch: [],
+    };
+    if ( st.q ) el.q.value = st.q;
+
+    // One row: the article, its current fields, what will be applied, and the AI's suggestions.
+    function makeRow( it ) {
+        const s = it.seo || {};
+        const cur = { t: s.title_from === 'field' ? clean( s.title ) : '', d: s.description_from === 'field' ? clean( s.description ) : '' };
+        const r = { it, cur, val: { ...cur }, ai: Array.isArray( it.ai ) && it.ai.length ? it.ai : null, k: { t: 0, d: 0 },
+                    status: '', msg: '', undoUntil: it.undo ? -1 : 0, preview: false, busy: false, aiErr: '' };
+        if ( r.ai ) defaultPicks( r );
+        return r;
+    }
+
+    // With fresh AI suggestions: use them where the current field is missing or weak, keep the rest.
+    function defaultPicks( r ) {
+        const s = r.it.seo || {};
+        const first = r.ai[ 0 ] || {};
+        if ( first.title && ( s.title_from !== 'field' || s.title_status !== 'ok' ) ) r.val.t = clean( first.title );
+        if ( first.description && ( s.description_from !== 'field' || s.description_status !== 'ok' ) ) r.val.d = clean( first.description );
+    }
+
+    const changed = r => ( r.val.t !== '' && r.val.t !== r.cur.t ) || ( r.val.d !== '' && r.val.d !== r.cur.d );
+    const pending = () => st.rows.filter( changed );
+
+    /* ------------------------------------------------------------------
+       Rendering
+    ------------------------------------------------------------------ */
+    function count( kind, text ) {
+        const n = chars( text );
+        const ctx = st.ctx || { title_max: 60, desc_min: 70, desc_max: 160, suffix: { mode: 'none', text: '' } };
+        if ( kind === 't' ) {
+            const extra = ctx.suffix && ctx.suffix.mode === 'auto' ? chars( ctx.suffix.text ) : 0;
+            const total = n + extra;
+            const cls = n === 0 ? 'none' : total > ctx.title_max ? 'warn' : 'ok';
+            return `<span class="clusterlane-seo-ai__count clusterlane-seo-ai__count--${ cls }">${ n ? ( extra ? n + ' + ' + extra + ' = ' : '' ) + total : 0 }/${ ctx.title_max }</span>`;
+        }
+        const cls = n === 0 ? 'none' : ( n < ctx.desc_min || n > ctx.desc_max ) ? 'warn' : 'ok';
+        return `<span class="clusterlane-seo-ai__count clusterlane-seo-ai__count--${ cls }">${ n } · ${ ctx.desc_min }–${ ctx.desc_max }</span>`;
+    }
+
+    // Tooltip text: what Restore original brings back.
+    function origTitle( o ) {
+        const v = x => x === null || x === undefined ? null : ( x === '' ? '(empty)' : x );
+        return [ 'Original before Clusterlane' + ( o.at ? ' (' + o.at.slice( 0, 10 ) + ')' : '' ) + ':',
+                 v( o.title ) !== null ? 'Title: ' + v( o.title ) : '', v( o.description ) !== null ? 'Description: ' + v( o.description ) : '' ].filter( Boolean ).join( '\n' );
+    }
+
+    function chips( r ) {
+        const s = r.it.seo || {};
+        const t = s.title_from !== 'field'
+            ? '<span class="clusterlane-serp__chip clusterlane-serp__chip--warn">No SEO title</span>'
+            : s.title_status === 'long' ? `<span class="clusterlane-serp__chip clusterlane-serp__chip--warn">Title ${ chars( s.title ) }</span>` : '<span class="clusterlane-serp__chip clusterlane-serp__chip--ok">Title</span>';
+        const d = {
+            missing: '<span class="clusterlane-serp__chip clusterlane-serp__chip--bad">No description</span>',
+            short:   '<span class="clusterlane-serp__chip clusterlane-serp__chip--warn">Description short</span>',
+            long:    '<span class="clusterlane-serp__chip clusterlane-serp__chip--warn">Description long</span>',
+        }[ s.description_status ] || ( s.description_from === 'excerpt' ? '<span class="clusterlane-serp__chip clusterlane-serp__chip--warn">Excerpt</span>' : '<span class="clusterlane-serp__chip clusterlane-serp__chip--ok">Description</span>' );
+        const others = n => `${ n } other${ n === 1 ? '' : 's' }`;
+        let x = '';
+        // Changed by Clusterlane (0.98.197): the original is kept and can be restored.
+        const o = s.original;
+        if ( o && o.differs ) x += `<span class="clusterlane-serp__chip clusterlane-seot__origchip" title="${ esc( origTitle( o ) ) }">Changed by Clusterlane</span>`;
+        if ( r.it.dup_title ) x += `<span class="clusterlane-serp__chip clusterlane-serp__chip--warn" title="The same SEO title as ${ others( r.it.dup_title ) }">Same title ×${ r.it.dup_title + 1 }</span>`;
+        if ( r.it.dup_desc )  x += `<span class="clusterlane-serp__chip clusterlane-serp__chip--warn" title="The same description as ${ others( r.it.dup_desc ) }">Same description ×${ r.it.dup_desc + 1 }</span>`;
+        const c = r.it.check;
+        if ( c ) {
+            const lo = Math.min( c.t, c.d );
+            x += `<span class="clusterlane-serp__chip clusterlane-serp__chip--${ lo <= 2 ? 'bad' : lo === 3 ? 'warn' : 'ok' } clusterlane-seot__aichip${ c.stale ? ' is-stale' : '' }" title="AI review: title ${ c.t }/5, description ${ c.d }/5${ c.stale ? ' — before the latest change' : '' }">AI ${ lo }/5</span>`;
+        }
+        return t + d + x;
+    }
+
+    // The AI line under a field: the suggestion, ‹ 1/3 › and Use / Keep current.
+    function aiLine( r, f ) {
+        if ( ! r.ai ) return '';
+        const key = f === 't' ? 'title' : 'description';
+        const list = r.ai.map( s => clean( s[ key ] ) ).filter( Boolean );
+        if ( ! list.length ) return '';
+        const k = Math.min( r.k[ f ], list.length - 1 );
+        const text = list[ k ];
+        const using = r.val[ f ] !== '' && list.includes( r.val[ f ] ) && r.val[ f ] !== r.cur[ f ];
+        const nav = list.length > 1
+            ? `<span class="clusterlane-seot__ai-nav"><button type="button" data-act="prev" data-f="${ f }" aria-label="Previous suggestion">‹</button>${ k + 1 }/${ list.length }<button type="button" data-act="next" data-f="${ f }" aria-label="Next suggestion">›</button></span>`
+            : '';
+        const act = using && r.val[ f ] === text
+            ? `<span class="clusterlane-seot__ai-on">${ glyph( 'check' ) }Using AI</span><button type="button" class="clusterlane-seot__link" data-act="keep" data-f="${ f }">${ r.cur[ f ] ? 'Keep current' : 'Don’t use' }</button>`
+            : `<button type="button" class="clusterlane-seot__use" data-act="use" data-f="${ f }">Use</button>`;
+        // In use: the text is already in the field above — say so instead of repeating it.
+        const inUse = using && r.val[ f ] === text;
+        const body = inUse
+            ? `<span class="clusterlane-seot__ai-text clusterlane-seot__ai-text--used">Suggestion ${ k + 1 } is in the field — edit it freely</span>`
+            : `<span class="clusterlane-seot__ai-text">${ esc( text ) }</span>`;
+        return `<div class="clusterlane-seot__ai${ inUse ? ' is-used' : '' }"><span class="clusterlane-seot__ai-tag">${ glyph( 'sparkle' ) }AI</span>${ body }<span class="clusterlane-seot__ai-acts">${ nav }${ act }</span></div>`;
+    }
+
+    // The AI review (Check with AI): score per field and the problem it found.
+    function reviewLine( r ) {
+        const c = r.it.check;
+        if ( ! c ) return '';
+        const part = ( label, n, issue ) => `<span class="clusterlane-seot__rv-part clusterlane-seot__rv-part--${ n <= 2 ? 'bad' : n === 3 ? 'warn' : 'ok' }"><b>${ label } ${ n }/5</b>${ issue ? ' — ' + esc( issue ) : '' }</span>`;
+        return `<p class="clusterlane-seot__review${ c.stale ? ' is-stale' : '' }"><span class="clusterlane-seot__ai-tag">${ glyph( 'sparkle' ) }AI review</span>${ part( 'Title', c.t, c.ti ) }${ part( 'Description', c.d, c.di ) }${ c.stale ? '<span class="clusterlane-seot__rv-stale">before the latest change — check again</span>' : '' }</p>`;
+    }
+
+    function fallbackNote( r, f ) {
+        const s = r.it.seo || {};
+        if ( f === 't' ) return s.title_from === 'post_title' ? 'Empty — search engines use the post title' : '';
+        if ( s.description_from === 'excerpt' ) return 'Empty — the excerpt is used';
+        return s.description_from === 'field' ? '' : 'Empty — search engines pick text from the page';
+    }
+
+    function serp( r ) {
+        const s = r.it.seo || {};
+        let host = '', crumbs = '';
+        try { const u = new URL( r.it.url ); host = u.host.replace( /^www\./, '' ); crumbs = u.pathname.split( '/' ).filter( Boolean ).map( decodeURIComponent ).join( ' › ' ); } catch ( e ) {}
+        const site = ( window.clusterlaneStickyBar && clusterlaneStickyBar.siteName ) || host;
+        const sfx  = st.ctx && st.ctx.suffix && st.ctx.suffix.mode === 'auto' ? st.ctx.suffix.text : '';
+        const title = ( r.val.t || ( s.title_from === 'field' ? '' : s.title ) || r.it.title ) + sfx;
+        const desc  = r.val.d || ( s.description_from === 'excerpt' ? s.description : '' );
+        return `<div class="clusterlane-serp clusterlane-seot__serp" aria-label="Search result preview">
+            <div class="clusterlane-serp__site"><span class="clusterlane-serp__fav" aria-hidden="true">${ esc( ( site || '?' ).trim().charAt( 0 ).toUpperCase() ) }</span>
+                <span><span class="clusterlane-serp__name">${ esc( site ) }</span><span class="clusterlane-serp__url">${ esc( host ) }${ crumbs ? ' › ' + esc( crumbs ) : '' }</span></span></div>
+            <div class="clusterlane-serp__title">${ esc( title ) }</div>
+            ${ desc ? `<div class="clusterlane-serp__desc">${ esc( desc ) }</div>` : '<div class="clusterlane-serp__desc clusterlane-serp__desc--none">No meta description — search engines pick text from the page themselves.</div>' }
+        </div>`;
+    }
+
+    // One line: title, the SEO title and description as they stand, their status, Edit.
+    function compactHtml( r ) {
+        const it = r.it, s = it.seo || {};
+        const inspect = ( clusterlaneData.articleInspectorUrl || 'admin.php?page=clusterlane-article-inspector' ) + '&post_id=' + it.id;
+        const isChanged = changed( r );
+        // A collapsed article with an edit not applied yet shows the new text and says so.
+        const tVal = r.val.t !== r.cur.t && r.val.t ? r.val.t : ( s.title_from === 'field' ? clean( s.title ) : '' );
+        const dVal = r.val.d !== r.cur.d && r.val.d ? r.val.d : ( s.description_from === 'field' || s.description_from === 'excerpt' ? clean( s.description ) : '' );
+        const state = isChanged ? '<span class="clusterlane-serp__chip clusterlane-seot__cstate">Not applied yet</span>'
+            : r.status === 'applied' ? `<span class="clusterlane-serp__chip clusterlane-serp__chip--ok">${ glyph( 'check' ) }Applied</span>` : '';
+        const tTxt = tVal ? esc( tVal ) : `<span class="clusterlane-seot__cnone">${ esc( shortTitle( it.title ) ) } <em>(post title)</em></span>`;
+        const dTxt = dVal ? esc( dVal ) + ( s.description_from === 'excerpt' ? ' <em class="clusterlane-seot__cnone">(excerpt)</em>' : '' ) : '<span class="clusterlane-seot__cnone">None — search engines pick text from the page</span>';
+        return `<article class="clusterlane-seot__row clusterlane-seot__row--compact${ st.sel.has( it.id ) ? ' is-selected' : '' }${ isChanged ? ' is-changed' : '' }" data-id="${ it.id }">
+            <input type="checkbox" class="clusterlane-seot__sel" aria-label="Select ${ esc( shortTitle( it.title ) ) }"${ st.sel.has( it.id ) ? ' checked' : '' }>
+            ${ thumb( it, 'sm' ) }
+            <div class="clusterlane-seot__name">
+                <a href="${ esc( inspect ) }">${ esc( shortTitle( it.title ) ) }</a>${ it.type === 'product' ? '<span class="clusterlane-lang-tag clusterlane-type-tag">Product</span>' : '' }${ it.lang ? `<span class="clusterlane-lang-tag">${ esc( it.lang ) }</span>` : '' }
+                <span class="clusterlane-seot__cchips">${ state }${ chips( r ) }</span>
+            </div>
+            <div class="clusterlane-seot__cval"><span class="clusterlane-seot__clbl">SEO title</span><span class="clusterlane-seot__ctext">${ tTxt }</span></div>
+            <div class="clusterlane-seot__cval clusterlane-seot__cval--desc"><span class="clusterlane-seot__clbl">Description</span><span class="clusterlane-seot__ctext">${ dTxt }</span></div>
+            <button type="button" class="clusterlane-tm-btn clusterlane-seot__edit" data-act="open" aria-label="Edit SEO title and description for ${ esc( shortTitle( it.title ) ) }">${ glyph( 'pencil' ) }Edit</button>
+        </article>`;
+    }
+
+    // AI for this one article (0.98.188): suggestions for the title and description,
+    // and a review — the same calls as the bulk buttons, without selecting first.
+    function aiButtons( r ) {
+        const pro = proReady();
+        const badge = pro ? '' : ' <span class="clusterlane-pro-badge">PRO</span>';
+        if ( r.busy ) return '';
+        return `<span class="clusterlane-seot__ai-one">`
+            + `<button type="button" class="clusterlane-tm-btn" data-act="${ r.ai ? 'ai-new' : 'ai' }">${ glyph( 'sparkle' ) }${ r.ai ? 'New suggestions' : 'Suggest with AI' }${ badge }</button>`
+            + `<button type="button" class="clusterlane-tm-btn" data-act="check">${ glyph( 'check' ) }${ r.it.check ? 'Check again' : 'Check with AI' }${ badge }</button>`
+            + `</span>`;
+    }
+    async function aiOne( r, force ) {
+        if ( ! proReady() ) { showExample(); return; }
+        let status = 0;
+        r.open = true; r.busy = true; r.msg = 'AI is writing suggestions…'; r.aiErr = ''; refreshRow( r );
+        await window.clusterlanePro.seoSuggest( {
+            postId: r.it.id, force: !! force,
+            apiFetch: ( p, o ) => apiFetch( p, o ).catch( err => { status = err.status || 0; throw err; } ),
+            render: d => { r.busy = false; r.ai = ( d.suggestions || [] ).length ? d.suggestions : null; r.k = { t: 0, d: 0 }; if ( r.ai ) defaultPicks( r ); refreshRow( r ); refreshBulk(); },
+            renderError: m => { r.busy = false; r.aiErr = m; refreshRow( r ); },
+        } );
+    }
+    async function checkOne( r ) {
+        if ( ! proReady() ) { showExample( 'check' ); return; }
+        r.open = true; r.busy = true; r.msg = 'AI is reviewing the title and description…'; r.aiErr = ''; refreshRow( r );
+        try {
+            const res = await post( 'seo/check', { post_ids: [ r.it.id ] } );
+            const c = ( res.results || {} )[ r.it.id ];
+            if ( c ) r.it.check = c; else r.aiErr = 'No review came back — try again.';
+        } catch ( e ) { r.aiErr = e.message || 'The review failed.'; }
+        r.busy = false; refreshRow( r ); scheduleCounts();
+    }
+
+    function rowHtml( r, example ) {
+        if ( ! example && ! isOpen( r ) ) return compactHtml( r );
+        const it = r.it;
+        const canWrite = !! ( st.ctx && st.ctx.can_write ) && it.can_edit !== false;
+        const isChanged = changed( r );
+        const inspect = ( clusterlaneData.articleInspectorUrl || 'admin.php?page=clusterlane-article-inspector' ) + '&post_id=' + it.id;
+        let foot;
+        if ( r.busy ) foot = `<span class="clusterlane-seot__state">${ esc( r.msg || 'Working…' ) }</span>`;
+        else if ( r.status === 'applied' ) foot = `<span class="clusterlane-seot__state clusterlane-seot__state--ok">${ glyph( 'check' ) }${ esc( r.msg ) }</span>${ r.undoUntil ? '<button type="button" class="clusterlane-seot__link" data-act="undo">Undo</button>' : '' }`;
+        else if ( r.status === 'error' ) foot = `<span class="clusterlane-seot__state clusterlane-seot__state--bad">${ esc( r.msg ) }</span>`;
+        else if ( r.undoUntil === -1 && ! isChanged ) foot = '<span class="clusterlane-seot__state">Changed recently</span><button type="button" class="clusterlane-seot__link" data-act="undo">Undo</button>';
+        else foot = isChanged ? '<span class="clusterlane-seot__state clusterlane-seot__state--changed">Not applied yet</span>' : '';
+        if ( r.aiErr ) foot += `<span class="clusterlane-seot__state clusterlane-seot__state--bad">${ esc( r.aiErr ) }</span>`;
+        const orig = ( it.seo || {} ).original;
+        if ( canWrite && ! example && ! r.busy && orig && orig.differs && ! isChanged ) foot += `<button type="button" class="clusterlane-seot__link" data-act="restore" title="${ esc( origTitle( orig ) ) }">Restore original</button>`;
+
+        const buttons = canWrite
+            ? `${ isChanged ? '<button type="button" class="clusterlane-tm-btn" data-act="reset">Reset</button>' : '' }<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-act="apply"${ isChanged && ! r.busy ? '' : ' disabled' }>Apply</button>`
+            : '<button type="button" class="clusterlane-tm-btn" data-act="copy-t">Copy title</button><button type="button" class="clusterlane-tm-btn" data-act="copy-d">Copy description</button>';
+
+        return `<article class="clusterlane-seot__row${ isChanged ? ' is-changed' : '' }${ r.status === 'applied' ? ' is-applied' : '' }${ st.sel.has( it.id ) ? ' is-selected' : '' }" data-id="${ it.id }">
+            <div class="clusterlane-seot__head">
+                ${ example ? '' : `<input type="checkbox" class="clusterlane-seot__sel" aria-label="Select ${ esc( shortTitle( it.title ) ) }"${ st.sel.has( it.id ) ? ' checked' : '' }>` }
+                ${ thumb( it, 'md' ) }
+                <div class="clusterlane-seot__name">
+                    ${ example ? `<span class="clusterlane-seot__name-text">${ esc( shortTitle( it.title ) ) }</span>` : `<a href="${ esc( inspect ) }">${ esc( shortTitle( it.title ) ) }</a>` }${ it.type === 'product' ? '<span class="clusterlane-lang-tag clusterlane-type-tag">Product</span>' : '' }${ it.lang ? `<span class="clusterlane-lang-tag">${ esc( it.lang ) }</span>` : '' }
+                    <span class="clusterlane-browse__cluster">${ it.cluster_label ? esc( it.cluster_label ) : 'No cluster' }</span>
+                </div>
+                <div class="clusterlane-seot__chips-row">${ chips( r ) }</div>
+                ${ ! example && view === 'compact' && ! r.busy ? `<button type="button" class="clusterlane-seot__link clusterlane-seot__collapse" data-act="close" title="${ changed( r ) ? 'Your change is kept — apply it later here or with Apply changes at the top' : 'Back to one line (Esc)' }">Collapse</button>` : '' }
+            </div>
+            ${ reviewLine( r ) }
+            <div class="clusterlane-seot__fields">
+                <div class="clusterlane-seot__field">
+                    <label class="clusterlane-seo-ai__lbl" for="clusterlane-seot-t-${ it.id }">SEO title <span data-count="t">${ count( 't', r.val.t ) }</span></label>
+                    <input type="text" id="clusterlane-seot-t-${ it.id }" class="clusterlane-seot__in" data-f="t" value="${ esc( r.val.t ) }" placeholder="${ esc( fallbackNote( r, 't' ) ? it.title : '' ) }">
+                    ${ fallbackNote( r, 't' ) && ! r.val.t ? `<p class="clusterlane-seot__from">${ esc( fallbackNote( r, 't' ) ) }</p>` : '' }
+                    ${ aiLine( r, 't' ) }
+                </div>
+                <div class="clusterlane-seot__field">
+                    <label class="clusterlane-seo-ai__lbl" for="clusterlane-seot-d-${ it.id }">Meta description <span data-count="d">${ count( 'd', r.val.d ) }</span></label>
+                    <textarea id="clusterlane-seot-d-${ it.id }" class="clusterlane-seot__in" data-f="d" rows="2" placeholder="${ esc( ( it.seo || {} ).description_from === 'excerpt' ? it.seo.description : '' ) }">${ esc( r.val.d ) }</textarea>
+                    ${ fallbackNote( r, 'd' ) && ! r.val.d ? `<p class="clusterlane-seot__from">${ esc( fallbackNote( r, 'd' ) ) }</p>` : '' }
+                    ${ aiLine( r, 'd' ) }
+                </div>
+            </div>
+            <div class="clusterlane-seot__foot">
+                ${ example ? '' : aiButtons( r ) }
+                <button type="button" class="clusterlane-seot__link" data-act="preview" aria-expanded="${ r.preview ? 'true' : 'false' }">${ glyph( 'search' ) }${ r.preview ? 'Hide preview' : 'Preview' }</button>
+                ${ it.edit_url ? `<a class="clusterlane-seot__link" href="${ esc( it.edit_url ) }" target="_blank" rel="noopener">${ glyph( 'pencil' ) }Edit post</a>` : '' }
+                <span class="clusterlane-seot__foot-state">${ foot }</span>
+                <span class="clusterlane-seot__foot-btns">${ buttons }</span>
+            </div>
+            ${ r.preview ? serp( r ) : '' }
+        </article>`;
+    }
+
+    // Re-render one row in place (keeps focus/caret if the user is typing in it).
+    function refreshRow( r ) {
+        const old = el.rows.querySelector( `.clusterlane-seot__row[data-id="${ r.it.id }"]` );
+        if ( ! old ) return;
+        const active = document.activeElement && old.contains( document.activeElement ) ? document.activeElement : null;
+        const f = active && active.dataset ? active.dataset.f : null;
+        const pos = active && typeof active.selectionStart === 'number' ? [ active.selectionStart, active.selectionEnd ] : null;
+        old.outerHTML = rowHtml( r );
+        if ( f ) {
+            const n = el.rows.querySelector( `.clusterlane-seot__row[data-id="${ r.it.id }"] .clusterlane-seot__in[data-f="${ f }"]` );
+            if ( n ) { n.focus(); if ( pos ) try { n.setSelectionRange( pos[0], pos[1] ); } catch ( e ) {} }
+        }
+    }
+
+    function refreshBulk() {
+        const n = st.sel.size;
+        const onPage = st.rows.length;
+        el.all.checked = onPage > 0 && st.rows.every( r => st.sel.has( r.it.id ) );
+        el.all.indeterminate = n > 0 && ! el.all.checked;
+        el.selected.textContent = n ? `${ n } selected` : 'Select all';
+        const label = el.suggest.querySelector( 'span' );
+        if ( label ) label.textContent = n ? `Suggest with AI (${ n })` : 'Suggest with AI';
+        el.suggest.disabled = !! st.running || ( n === 0 && proReady() );
+        if ( el.check ) el.check.disabled = !! st.running;
+        const p = pending().length;
+        const canWrite = !! ( st.ctx && st.ctx.can_write );
+        el.applyAll.hidden = ! canWrite;
+        el.applyAll.disabled = ! p || !! st.running;
+        el.applyAll.textContent = p ? `Apply ${ p } change${ p === 1 ? '' : 's' }` : 'Apply changes';
+    }
+
+    /* ------------------------------------------------------------------
+       Loading
+    ------------------------------------------------------------------ */
+    // Export CSV (0.98.166): every article matching the search, cluster and chip — saved values, not unsaved edits.
+    const csvBtn = document.getElementById( 'clusterlane-seot-csv' );
+    if ( csvBtn ) csvBtn.addEventListener( 'click', async () => {
+        const qs = new URLSearchParams( { seo: 1, sort: 'meta', export: 1 } );
+        if ( st.q ) qs.set( 'q', st.q );
+        if ( st.cluster ) qs.set( 'cluster', st.cluster );
+        if ( st.meta ) qs.set( 'meta', st.meta );
+        if ( st.lang ) qs.set( 'lang', st.lang );
+        csvBtn.disabled = true;
+        try {
+            const items = await window.clusterlaneExportAll( page => { qs.set( 'page', page ); return apiFetch( 'inspector/articles?' + qs.toString() ); }, csvBtn );
+            const data = { items };
+            const len  = v => ( v || '' ).length;
+            const chk  = ( r, k ) => r.check && ! r.check.stale ? r.check[ k ] : '';
+            window.clusterlaneCsv( 'clusterlane-seo-titles', [
+                [ 'ID', r => r.id ], [ 'Title', r => r.title ], [ 'URL', r => r.url ], [ 'Cluster', r => r.cluster_label ],
+                [ 'SEO title', r => ( r.seo || {} ).title ], [ 'Title length', r => len( ( r.seo || {} ).title ) ],
+                [ 'Title status', r => ( r.seo || {} ).title_status ], [ 'Title from', r => ( r.seo || {} ).title_from ],
+                [ 'Meta description', r => ( r.seo || {} ).description ], [ 'Description length', r => len( ( r.seo || {} ).description ) ],
+                [ 'Description status', r => ( r.seo || {} ).description_status ], [ 'Description from', r => ( r.seo || {} ).description_from ],
+                [ 'Same title on', r => r.dup_title || '' ], [ 'Same description on', r => r.dup_desc || '' ],
+                [ 'AI title (1-5)', r => chk( r, 't' ) ], [ 'AI title note', r => chk( r, 'ti' ) ],
+                [ 'AI description (1-5)', r => chk( r, 'd' ) ], [ 'AI description note', r => chk( r, 'di' ) ],
+            ].concat( langSel ? [ [ 'Language', r => r.lang ] ] : [] ), data.items || [] );
+        } catch ( e ) { window.alert( 'Could not export: ' + ( e.message || e ) ); }
+        csvBtn.disabled = false;
+    } );
+
+    // Compact / Full (0.98.187)
+    const viewSeg = $( 'clusterlane-seot-view' );
+    function paintView() {
+        if ( ! viewSeg ) return;
+        viewSeg.querySelectorAll( 'button[data-view]' ).forEach( b => {
+            const on = b.dataset.view === view;
+            b.classList.toggle( 'is-active', on ); b.setAttribute( 'aria-pressed', on ? 'true' : 'false' );
+        } );
+        root.classList.toggle( 'clusterlane-seot--compact', view === 'compact' );
+    }
+    if ( viewSeg ) viewSeg.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-view]' ); if ( ! b || b.dataset.view === view ) return;
+        view = b.dataset.view;
+        try { localStorage.setItem( 'clusterlane-seot-view', view ); } catch ( err ) {}
+        st.rows.forEach( r => { r.open = undefined; } ); // back to automatic
+        paintView();
+        el.rows.innerHTML = st.rows.length ? st.rows.map( r => rowHtml( r ) ).join( '' ) : el.rows.innerHTML;
+    } );
+    paintView();
+
+    // Chips with counts (for the current search + cluster).
+    function paintCounts( mc ) {
+        el.meta.querySelectorAll( 'button[data-meta]' ).forEach( b => {
+            const m = b.dataset.meta, em = b.querySelector( 'em' );
+            if ( em ) em.textContent = m ? ( mc[ m ] || 0 ) : '';
+            b.classList.toggle( 'is-active', m === st.meta );
+            b.setAttribute( 'aria-pressed', m === st.meta ? 'true' : 'false' );
+            // Long titles can only be measured in custom fields (SEO plugins use templates).
+            if ( m === 'title' ) b.hidden = !! ( st.ctx && st.ctx.source_id !== 'custom' );
+            // AI: poor match — only once something has been reviewed (Pro); Duplicates — only when there are any.
+            if ( m === 'ai' ) b.hidden = ! ( mc.ai_checked || st.meta === 'ai' );
+            if ( m === 'dup' ) b.hidden = ! ( mc.dup || st.meta === 'dup' );
+        } );
+
+    }
+
+    // After Apply, Undo or an AI review the counts change: fetch them again without
+    // re-rendering the list (0.98.190; they used to stay until the page was reloaded).
+    let countsTimer = null, countsReq = 0;
+    function scheduleCounts() {
+        clearTimeout( countsTimer );
+        countsTimer = setTimeout( async () => {
+            const my = ++countsReq;
+            const qs = new URLSearchParams( { seo: 1, sort: 'meta', page: 1, per_page: 5 } );
+            if ( st.q ) qs.set( 'q', st.q );
+            if ( st.cluster ) qs.set( 'cluster', st.cluster );
+            if ( st.meta ) qs.set( 'meta', st.meta );
+            if ( st.lang ) qs.set( 'lang', st.lang );
+            try { const d = await apiFetch( 'inspector/articles?' + qs.toString() ); if ( my === countsReq ) paintCounts( d.meta_counts || {} ); } catch ( e ) {}
+        }, 400 );
+    }
+
+    async function load() {
+        const my = ++st.req;
+        root.classList.add( 'is-loading' );
+        const qs = new URLSearchParams( { seo: 1, sort: 'meta', page: st.page, per_page: 20 } );
+        if ( st.q ) qs.set( 'q', st.q );
+        if ( st.cluster ) qs.set( 'cluster', st.cluster );
+        if ( st.meta ) qs.set( 'meta', st.meta );
+        if ( st.lang ) qs.set( 'lang', st.lang );
+        let data;
+        try { data = await apiFetch( 'inspector/articles?' + qs.toString() ); }
+        catch ( e ) { if ( my === st.req ) { el.rows.innerHTML = `<p class="clusterlane-browse__empty">${ esc( e.message || 'Could not load articles.' ) }</p>`; root.classList.remove( 'is-loading' ); } return; }
+        if ( my !== st.req ) return;
+        root.classList.remove( 'is-loading' );
+
+        st.ctx  = data.seo_ctx || st.ctx;
+        st.rows = ( data.items || [] ).map( makeRow );
+        st.byId = {};
+        st.rows.forEach( r => { st.byId[ r.it.id ] = r; } );
+        st.sel.clear();
+
+        paintCounts( data.meta_counts || {} );
+
+        const total = parseInt( data.total, 10 ) || 0;
+        const src = st.ctx ? `<span class="clusterlane-browse__sum-part">${ esc( st.ctx.source ) }</span>` : '';
+        el.summary.innerHTML = `<span class="clusterlane-browse__sum-part"><b>${ total }</b> article${ total === 1 ? '' : 's' }</span><span class="clusterlane-browse__sum-part clusterlane-browse__sum-part--sort"><span aria-hidden="true">↓</span> weakest first</span>${ src }`;
+
+        el.note.innerHTML = st.ctx && ! st.ctx.can_write && st.ctx.write_note
+            ? `<p class="clusterlane-seo-ai__note clusterlane-seot__note">${ esc( st.ctx.write_note ) }</p>`
+            : ( st.ctx && st.ctx.can_write ? `<p class="clusterlane-seot__keep">${ glyph( 'check' ) }The first time Clusterlane changes an article’s SEO title or description, the original is kept — <b>Restore original</b> on the article brings it back.</p>` : '' );
+
+        el.rows.innerHTML = st.rows.length ? st.rows.map( r => rowHtml( r ) ).join( '' ) : '<p class="clusterlane-browse__empty">No articles match these filters.</p>';
+
+        const from = data.total ? ( data.page - 1 ) * data.per_page + 1 : 0;
+        const to   = Math.min( data.total, data.page * data.per_page );
+        el.pager.innerHTML = data.pages > 1
+            ? `<span class="clusterlane-browse__muted">${ from }–${ to } of ${ data.total }</span>
+               <div class="clusterlane-browse__pages">
+                 <button type="button" data-page="${ data.page - 1 }" ${ data.page <= 1 ? 'disabled' : '' } aria-label="Previous page">‹ Prev</button>
+                 <span>Page ${ data.page } of ${ data.pages }</span>
+                 <button type="button" data-page="${ data.page + 1 }" ${ data.page >= data.pages ? 'disabled' : '' } aria-label="Next page">Next ›</button>
+               </div>`
+            : '';
+        refreshBulk();
+    }
+
+    // Leaving the page (filter, paging) drops edits that were not applied — ask first.
+    function okToLeave() {
+        const n = pending().length;
+        return ! n || window.confirm( `${ n } change${ n === 1 ? ' is' : 's are' } not applied yet and will be lost. Continue?` );
+    }
+    window.addEventListener( 'beforeunload', e => { if ( pending().length || st.running ) { e.preventDefault(); e.returnValue = ''; } } );
+
+    /* ------------------------------------------------------------------
+       Row actions
+    ------------------------------------------------------------------ */
+    function flash( r, status, msg ) { r.status = status; r.msg = msg; }
+
+    async function applyRow( r ) {
+        const body = { post_id: r.it.id };
+        if ( r.val.t !== '' && r.val.t !== r.cur.t ) body.title = r.val.t;
+        if ( r.val.d !== '' && r.val.d !== r.cur.d ) body.description = r.val.d;
+        if ( ! body.title && ! body.description ) return false;
+        r.busy = true; r.msg = 'Applying…'; refreshRow( r );
+        try {
+            const res = await post( 'seo/apply', body );
+            r.busy = false;
+            r.it.seo = res.seo || r.it.seo;
+            if ( ( res.written || [] ).length ) r.it.check = null; // the review was of the old text
+            r.cur = { t: r.it.seo.title_from === 'field' ? clean( r.it.seo.title ) : '', d: r.it.seo.description_from === 'field' ? clean( r.it.seo.description ) : '' };
+            r.val = { ...r.cur };
+            const what = [ body.title ? 'title' : '', body.description ? 'description' : '' ].filter( Boolean ).join( ' and ' );
+            r.undoUntil = res.undo_until || 0;
+            const until = r.undoUntil ? new Date( r.undoUntil * 1000 ).toLocaleTimeString( [], { hour: '2-digit', minute: '2-digit' } ) : '';
+            flash( r, 'applied', `Applied ${ what }${ until ? ' · undo until ' + until : '' }` );
+            refreshRow( r ); refreshBulk(); scheduleCounts();
+            return true;
+        } catch ( e ) {
+            r.busy = false; flash( r, 'error', e.message || 'Could not apply.' ); refreshRow( r ); refreshBulk();
+            return false;
+        }
+    }
+
+    async function undoRow( r ) {
+        r.busy = true; r.msg = 'Undoing…'; refreshRow( r );
+        try {
+            const res = await post( 'seo/undo', { post_id: r.it.id } );
+            r.busy = false;
+            r.it.seo = res.seo || r.it.seo;
+            r.cur = { t: r.it.seo.title_from === 'field' ? clean( r.it.seo.title ) : '', d: r.it.seo.description_from === 'field' ? clean( r.it.seo.description ) : '' };
+            r.val = { ...r.cur }; r.undoUntil = 0; r.it.check = null;
+            flash( r, "", "" ); refreshRow( r ); refreshBulk(); scheduleCounts();
+            return true;
+        } catch ( e ) {
+            r.busy = false; r.undoUntil = 0; flash( r, 'error', e.message || 'Could not undo.' ); refreshRow( r );
+            return false;
+        }
+    }
+
+    // Restore original (0.98.197): the fields as they were before Clusterlane first changed them; Undo for 30 minutes.
+    async function restoreRow( r ) {
+        r.busy = true; r.msg = 'Restoring…'; refreshRow( r );
+        try {
+            const res = await post( 'seo/restore', { post_id: r.it.id } );
+            r.busy = false;
+            r.it.seo = res.seo || r.it.seo;
+            r.cur = { t: r.it.seo.title_from === 'field' ? clean( r.it.seo.title ) : '', d: r.it.seo.description_from === 'field' ? clean( r.it.seo.description ) : '' };
+            r.val = { ...r.cur }; r.it.check = null;
+            r.undoUntil = res.undo_until || 0;
+            const until = r.undoUntil ? new Date( r.undoUntil * 1000 ).toLocaleTimeString( [], { hour: '2-digit', minute: '2-digit' } ) : '';
+            flash( r, 'applied', 'Original restored' + ( until ? ' · undo until ' + until : '' ) );
+            refreshRow( r ); refreshBulk(); scheduleCounts();
+        } catch ( e ) {
+            r.busy = false; flash( r, 'error', e.message || 'Could not restore.' ); refreshRow( r );
+        }
+    }
+
+    async function copy( text, btn ) {
+        try { await navigator.clipboard.writeText( text ); }
+        catch ( e ) { const ta = document.createElement( 'textarea' ); ta.value = text; document.body.appendChild( ta ); ta.select(); try { document.execCommand( 'copy' ); } catch ( e2 ) {} ta.remove(); }
+        const label = btn.textContent; btn.textContent = 'Copied ✓'; setTimeout( () => { btn.textContent = label; }, 1400 );
+    }
+
+    el.rows.addEventListener( 'click', e => {
+        const rowEl = e.target.closest( '.clusterlane-seot__row' );
+        if ( ! rowEl ) return;
+        const r = st.byId[ rowEl.dataset.id ];
+        if ( ! r ) return;
+        if ( e.target.classList.contains( 'clusterlane-seot__sel' ) ) {
+            if ( e.target.checked ) st.sel.add( r.it.id ); else st.sel.delete( r.it.id );
+            rowEl.classList.toggle( 'is-selected', e.target.checked );
+            refreshBulk();
+            return;
+        }
+        const b = e.target.closest( '[data-act]' );
+        if ( ! b || b.disabled || r.busy ) return;
+        const f = b.dataset.f, act = b.dataset.act;
+        const key = f === 't' ? 'title' : 'description';
+        if ( act === 'prev' || act === 'next' ) {
+            const list = r.ai.map( s => clean( s[ key ] ) ).filter( Boolean ), n = list.length;
+            const wasUsed = r.val[ f ] === list[ Math.min( r.k[ f ], n - 1 ) ] && r.val[ f ] !== r.cur[ f ];
+            r.k[ f ] = ( r.k[ f ] + ( act === 'next' ? 1 : n - 1 ) ) % n;
+            if ( wasUsed ) r.val[ f ] = list[ r.k[ f ] ]; // browsing while in use swaps the text in the field
+            refreshRow( r ); refreshBulk(); return;
+        }
+        if ( act === 'use' ) { const list = r.ai.map( s => clean( s[ key ] ) ).filter( Boolean ); r.val[ f ] = list[ Math.min( r.k[ f ], list.length - 1 ) ]; flash( r, '', '' ); refreshRow( r ); refreshBulk(); return; }
+        if ( act === 'keep' ) { r.val[ f ] = r.cur[ f ]; refreshRow( r ); refreshBulk(); return; }
+        if ( act === 'reset' ) { r.val = { ...r.cur }; flash( r, '', '' ); refreshRow( r ); refreshBulk(); return; }
+        if ( act === 'preview' ) { r.preview = ! r.preview; refreshRow( r ); return; }
+        if ( act === 'ai' || act === 'ai-new' ) { aiOne( r, act === 'ai-new' ); return; }
+        if ( act === 'check' ) { checkOne( r ); return; }
+        if ( act === 'open' ) {
+            r.open = true; refreshRow( r );
+            const n = el.rows.querySelector( `.clusterlane-seot__row[data-id="${ r.it.id }"] .clusterlane-seot__in[data-f="t"]` );
+            if ( n ) n.focus();
+            return;
+        }
+        if ( act === 'close' ) {
+            r.open = false; r.preview = false; if ( r.status === 'error' ) { r.status = ''; r.msg = ''; } refreshRow( r );
+            const n = el.rows.querySelector( `.clusterlane-seot__row[data-id="${ r.it.id }"] .clusterlane-seot__edit` );
+            if ( n ) n.focus();
+            return;
+        }
+        if ( act === 'apply' ) { applyRow( r ); return; }
+        if ( act === 'undo' ) { undoRow( r ); return; }
+        if ( act === 'restore' ) { restoreRow( r ); return; }
+        if ( act === 'copy-t' ) { copy( r.val.t || r.cur.t || r.it.title, b ); return; }
+        if ( act === 'copy-d' ) { copy( r.val.d || r.cur.d, b ); return; }
+    } );
+
+    // Esc in an open card collapses it (compact view), keeping any change not applied yet.
+    el.rows.addEventListener( 'keydown', e => {
+        if ( e.key !== 'Escape' || view !== 'compact' ) return;
+        const rowEl = e.target.closest( '.clusterlane-seot__row' );
+        const r = rowEl && st.byId[ rowEl.dataset.id ];
+        if ( ! r || r.busy || ! isOpen( r ) ) return;
+        e.preventDefault();
+        r.open = false; r.preview = false; refreshRow( r );
+        const n = el.rows.querySelector( `.clusterlane-seot__row[data-id="${ r.it.id }"] .clusterlane-seot__edit` );
+        if ( n ) n.focus();
+    } );
+
+    // Typing: update the value, the counter, the preview and the Apply state without re-rendering the field.
+    el.rows.addEventListener( 'input', e => {
+        const f = e.target.dataset && e.target.dataset.f;
+        if ( ! f || ! e.target.classList.contains( 'clusterlane-seot__in' ) ) return;
+        const rowEl = e.target.closest( '.clusterlane-seot__row' );
+        const r = st.byId[ rowEl.dataset.id ];
+        if ( ! r ) return;
+        r.val[ f ] = clean( e.target.value );
+        if ( r.status ) flash( r, '', '' );
+        const slot = rowEl.querySelector( `[data-count="${ f }"]` );
+        if ( slot ) slot.innerHTML = count( f, r.val[ f ] );
+        const isChanged = changed( r );
+        rowEl.classList.toggle( 'is-changed', isChanged );
+        const apply = rowEl.querySelector( '[data-act="apply"]' );
+        if ( apply ) apply.disabled = ! isChanged;
+        const state = rowEl.querySelector( '.clusterlane-seot__foot-state' );
+        if ( state ) state.innerHTML = isChanged ? '<span class="clusterlane-seot__state clusterlane-seot__state--changed">Not applied yet</span>' : '';
+        if ( r.preview ) { const p = rowEl.querySelector( '.clusterlane-seot__serp' ); if ( p ) p.outerHTML = serp( r ); }
+        // The AI line follows: "Using AI" only while the field holds the AI's text.
+        const ai = e.target.closest( '.clusterlane-seot__field' ).querySelector( '.clusterlane-seot__ai' );
+        if ( ai ) ai.outerHTML = aiLine( r, f );
+        refreshBulk();
+    } );
+
+    /* ------------------------------------------------------------------
+       Bulk: select, apply every change, AI for the selected
+    ------------------------------------------------------------------ */
+    el.all.addEventListener( 'change', () => {
+        if ( el.all.checked ) st.rows.forEach( r => st.sel.add( r.it.id ) ); else st.sel.clear();
+        el.rows.querySelectorAll( '.clusterlane-seot__row' ).forEach( n => {
+            const on = st.sel.has( parseInt( n.dataset.id, 10 ) );
+            n.classList.toggle( 'is-selected', on );
+            const cb = n.querySelector( '.clusterlane-seot__sel' ); if ( cb ) cb.checked = on;
+        } );
+        refreshBulk();
+    } );
+
+    function runBox( html ) { el.run.hidden = ! html; el.run.innerHTML = html || ''; }
+
+    el.applyAll.addEventListener( 'click', async () => {
+        const list = pending();
+        if ( ! list.length ) return;
+        st.running = { stop: false };
+        refreshBulk();
+        const done = [];
+        for ( let i = 0; i < list.length; i++ ) {
+            if ( st.running.stop ) break;
+            runBox( `<span>${ glyph( 'check' ) }Applying ${ i + 1 } of ${ list.length }…</span><button type="button" class="clusterlane-seot__link" data-run="stop">Stop</button>` );
+            if ( await applyRow( list[ i ] ) ) done.push( list[ i ].it.id );
+        }
+        st.running = null;
+        st.batch = done;
+        runBox( done.length
+            ? `<span class="clusterlane-seot__state--ok">${ glyph( 'check' ) }Applied ${ done.length } article${ done.length === 1 ? '' : 's' }.</span> <button type="button" class="clusterlane-seot__link" data-run="undo-batch">Undo all ${ done.length }</button>`
+            : '' );
+        refreshBulk();
+    } );
+
+    el.suggest.addEventListener( 'click', () => {
+        if ( ! proReady() ) { showExample(); return; }
+        const sel = st.rows.filter( r => st.sel.has( r.it.id ) );
+        if ( ! sel.length ) return;
+        const need = sel.filter( r => ! r.ai );
+        const have = sel.length - need.length;
+        if ( ! need.length ) {
+            runBox( `<span>${ glyph( 'sparkle' ) }All ${ sel.length } selected already have AI suggestions — shown under each field.</span>` );
+            return;
+        }
+        runBox( `<span>${ glyph( 'sparkle' ) }<b>${ need.length }</b> article${ need.length === 1 ? ' needs' : 's need' } an AI call${ have ? ` (${ have } already ${ have === 1 ? 'has' : 'have' } suggestions)` : '' } — about ${ need.length } AI call${ need.length === 1 ? '' : 's' }. Nothing is written until you apply.</span>
+            <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-run="start">Start</button><button type="button" class="clusterlane-seot__link" data-run="cancel">Cancel</button>` );
+        st.pendingRun = need;
+    } );
+
+    /* Check with AI: review every article in the current filter (all pages),
+       10 per AI call; articles reviewed before and unchanged are skipped. */
+    el.check.addEventListener( 'click', async () => {
+        if ( ! proReady() ) { showExample( 'check' ); return; }
+        const qs = new URLSearchParams();
+        if ( st.q ) qs.set( 'q', st.q );
+        if ( st.cluster ) qs.set( 'cluster', st.cluster );
+        runBox( `<span>${ glyph( 'sparkle' ) }Counting articles…</span>` );
+        let plan;
+        try { plan = await apiFetch( 'seo/check-plan?' + qs.toString() ); }
+        catch ( e ) { runBox( `<span class="clusterlane-seot__state--bad">${ esc( e.message ) }</span>` ); return; }
+        const scope = st.cluster || st.q ? 'in this filter' : 'on the site';
+        if ( ! plan.need.length ) {
+            runBox( `<span>${ glyph( 'check' ) }All ${ plan.total } articles ${ scope } are reviewed and unchanged since.</span>${ plan.total ? '<button type="button" class="clusterlane-seot__link" data-run="show-ai">Show poor matches</button>' : '' }` );
+            return;
+        }
+        st.pendingCheck = plan;
+        runBox( `<span>${ glyph( 'sparkle' ) }AI reads each article and rates its SEO title and description 1–5. <b>${ plan.need.length }</b> of ${ plan.total } articles ${ scope } need a review${ plan.current ? ` (${ plan.current } reviewed and unchanged)` : '' } — ${ plan.calls } AI call${ plan.calls === 1 ? '' : 's' }, ${ plan.batch } articles each. Nothing is written.</span>
+            <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-run="check-start">Start</button><button type="button" class="clusterlane-seot__link" data-run="cancel">Cancel</button>` );
+    } );
+
+    async function runCheck( plan ) {
+        st.running = { stop: false };
+        refreshBulk();
+        const ids = plan.need.slice();
+        let done = 0, poor = 0, stopMsg = '';
+        for ( let i = 0; i < ids.length; i += plan.batch ) {
+            if ( st.running.stop ) { stopMsg = 'Stopped.'; break; }
+            const chunk = ids.slice( i, i + plan.batch );
+            runBox( `<span>${ glyph( 'sparkle' ) }Reviewing ${ Math.min( i + chunk.length, ids.length ) } of ${ ids.length }…</span><button type="button" class="clusterlane-seot__link" data-run="stop">Stop</button>` );
+            try {
+                const res = await post( 'seo/check', { post_ids: chunk } );
+                Object.entries( res.results || {} ).forEach( ( [ id, c ] ) => {
+                    done++;
+                    if ( Math.min( c.t, c.d ) <= 2 ) poor++;
+                    const r = st.byId[ id ];
+                    if ( r ) { r.it.check = c; refreshRow( r ); }
+                } );
+            } catch ( e ) {
+                stopMsg = e.message || 'The review stopped.';
+                if ( e.status === 429 || /limit|quota|429/i.test( stopMsg ) || e.status === 403 || e.status === 400 ) break;
+            }
+        }
+        st.running = null;
+        scheduleCounts();
+        runBox( `<span>${ glyph( 'sparkle' ) }Reviewed ${ done } article${ done === 1 ? '' : 's' } — <b>${ poor }</b> poor match${ poor === 1 ? '' : 'es' }.${ stopMsg ? ' ' + esc( stopMsg ) : '' }</span>${ poor ? '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" data-run="show-ai">Show poor matches</button>' : '' }` );
+        refreshBulk();
+    }
+
+    async function runAi( list ) {
+        st.running = { stop: false };
+        refreshBulk();
+        let ok = 0, failed = 0, stopMsg = '';
+        for ( let i = 0; i < list.length; i++ ) {
+            if ( st.running.stop ) { stopMsg = 'Stopped.'; break; }
+            const r = list[ i ];
+            runBox( `<span>${ glyph( 'sparkle' ) }Suggesting ${ i + 1 } of ${ list.length }: ${ esc( shortTitle( r.it.title ) ) }…</span><button type="button" class="clusterlane-seot__link" data-run="stop">Stop</button>` );
+            r.busy = true; r.msg = 'AI is writing suggestions…'; r.aiErr = ''; if ( r.open === false ) r.open = undefined; refreshRow( r );
+            let status = 0;
+            await window.clusterlanePro.seoSuggest( {
+                postId: r.it.id, force: false,
+                apiFetch: ( p, o ) => apiFetch( p, o ).catch( err => { status = err.status || 0; throw err; } ),
+                render: d => { r.busy = false; r.ai = ( d.suggestions || [] ).length ? d.suggestions : null; if ( r.ai ) { defaultPicks( r ); ok++; } refreshRow( r ); },
+                renderError: m => { r.busy = false; r.aiErr = m; failed++; refreshRow( r ); if ( status === 429 || /limit|quota|429/i.test( m ) ) stopMsg = m; },
+            } );
+            if ( stopMsg ) break;
+        }
+        st.running = null;
+        const p = pending().length;
+        runBox( `<span>${ glyph( 'sparkle' ) }${ ok } article${ ok === 1 ? '' : 's' } got suggestions${ failed ? `, ${ failed } failed` : '' }.${ stopMsg ? ' ' + esc( stopMsg ) : '' }${ p ? ` ${ p } change${ p === 1 ? '' : 's' } ready — check them, then apply.` : '' }</span>` );
+        refreshBulk();
+    }
+
+    el.run.addEventListener( 'click', async e => {
+        const b = e.target.closest( '[data-run]' );
+        if ( ! b ) return;
+        const a = b.dataset.run;
+        if ( a === 'stop' && st.running ) { st.running.stop = true; b.disabled = true; b.textContent = 'Stopping…'; return; }
+        if ( a === 'cancel' ) { st.pendingRun = null; st.pendingCheck = null; runBox( '' ); return; }
+        if ( a === 'start' && st.pendingRun ) { const l = st.pendingRun; st.pendingRun = null; runAi( l ); return; }
+        if ( a === 'check-start' && st.pendingCheck ) { const pl = st.pendingCheck; st.pendingCheck = null; runCheck( pl ); return; }
+        if ( a === 'show-ai' ) { if ( ! okToLeave() ) return; runBox( '' ); st.meta = 'ai'; st.page = 1; load(); return; }
+        if ( a === 'undo-batch' ) {
+            const ids = st.batch.slice(); st.batch = [];
+            let n = 0;
+            for ( const id of ids ) { const r = st.byId[ id ]; if ( r && await undoRow( r ) ) n++; }
+            runBox( `<span>Undone ${ n } of ${ ids.length }.</span>` );
+            refreshBulk();
+        }
+    } );
+
+    /* ------------------------------------------------------------------
+       Free: the Lisbon example, frozen, with the Pro banner
+    ------------------------------------------------------------------ */
+    function showExample( mode ) {
+        const ex = typeof window.clusterlaneProExample === 'function' ? window.clusterlaneProExample( 'seo_titles' ) : null;
+        if ( ! ex || ! ex.items ) return;
+        const saved = st.ctx;
+        st.ctx = { ...( st.ctx || {} ), ...( ex.ctx || {} ), can_write: true };
+        const rows = ex.items.map( makeRow );
+        el.example.innerHTML = ( typeof window.clusterlaneProBanner === 'function' ? window.clusterlaneProBanner( mode === 'check' ? 'AI review of SEO titles' : 'AI suggestions for SEO titles' ) : '' )
+            + `<div class="clusterlane-seot__list clusterlane-seot__example">${ rows.map( r => rowHtml( r, true ) ).join( '' ) }</div>`;
+        st.ctx = saved;
+        if ( typeof window.clusterlaneFreezeExample === 'function' ) window.clusterlaneFreezeExample( el.example.querySelector( '.clusterlane-seot__example' ) );
+        el.example.scrollIntoView( { block: 'nearest', behavior: 'smooth' } );
+    }
+
+    /* ------------------------------------------------------------------
+       Filters and paging
+    ------------------------------------------------------------------ */
+    el.meta.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-meta]' );
+        if ( ! b || ! okToLeave() ) return;
+        st.meta = b.dataset.meta; st.page = 1; load();
+    } );
+    let qTimer;
+    el.q.addEventListener( 'input', () => {
+        clearTimeout( qTimer );
+        qTimer = setTimeout( () => { if ( ! okToLeave() ) { el.q.value = st.q; return; } st.q = el.q.value.trim(); st.page = 1; load(); }, 300 );
+    } );
+    const langSel = document.getElementById( 'clusterlane-seot-lang' ); // multilingual sites only (0.98.168)
+    if ( langSel ) langSel.addEventListener( 'change', () => {
+        if ( ! okToLeave() ) { langSel.value = st.lang; return; }
+        st.lang = langSel.value; st.page = 1; load();
+    } );
+    el.cluster.addEventListener( 'change', () => {
+        if ( ! okToLeave() ) { el.cluster.value = st.cluster; return; }
+        st.cluster = el.cluster.value; st.page = 1; load();
+    } );
+    el.pager.addEventListener( 'click', e => {
+        const b = e.target.closest( 'button[data-page]' );
+        if ( ! b || b.disabled || ! okToLeave() ) return;
+        st.page = parseInt( b.dataset.page, 10 ); load();
+        root.scrollIntoView( { block: 'start', behavior: 'smooth' } );
+    } );
+
+    // Free: the AI button is a Pro teaser (always clickable).
+    if ( ! proReady() ) {
+        el.suggest.insertAdjacentHTML( 'beforeend', ' <span class="clusterlane-pro-badge">PRO</span>' );
+        el.check.insertAdjacentHTML( 'beforeend', ' <span class="clusterlane-pro-badge">PRO</span>' );
+    }
+
+    apiFetch( 'clusters' ).then( d => {
+        const list = ( Array.isArray( d ) ? d : ( d.clusters || [] ) )
+            .filter( c => String( c.is_active ) === '1' )
+            .sort( ( a, b ) => a.cluster_label.localeCompare( b.cluster_label ) );
+        el.cluster.insertAdjacentHTML( 'beforeend', list.map( c =>
+            `<option value="${ esc( c.cluster_key ) }">${ esc( c.cluster_label ) } (${ parseInt( c.total_members, 10 ) || 0 })</option>` ).join( '' ) );
+        if ( st.cluster ) el.cluster.value = st.cluster;
+    } ).catch( () => {} );
+
+    load();
+} )();
Index: /clusterlane/tags/1.3.2/admin/js/settings-page.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/settings-page.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/settings-page.js	(revision 3751027)
@@ -0,0 +1,177 @@
+/* Clusterlane — Settings page: tabs + "unsaved changes" bar.
+ *
+ * One form, four tabs (General · Content · Link styles · Advanced). The save
+ * bar appears only when something differs from what was loaded, says how many
+ * fields changed and in which tabs, and warns before leaving with unsaved
+ * changes. The AI prompts (rendered inside the AI section) are moved to the
+ * Advanced tab, where they belong.
+ */
+(function () {
+    'use strict';
+    var form = document.getElementById('clusterlane-settings-form');
+    if (!form) return;
+    var tabs = Array.prototype.slice.call(form.querySelectorAll('.clusterlane-set-tabs [data-tab]'));
+    var panels = Array.prototype.slice.call(form.querySelectorAll('.clusterlane-set-panel'));
+    var field = document.getElementById('clusterlane-set-tab-field');
+    var NAMES = { general: 'General', content: 'Content', styles: 'Link styles', advanced: 'Advanced' };
+
+    // AI prompts → Advanced.
+    var prompts = form.querySelector('.clusterlane-prompt-editor-wrap'), slot = document.getElementById('clusterlane-set-prompts-slot');
+    if (prompts && slot) {
+        prompts.style.borderTop = '0'; prompts.style.marginTop = '0'; prompts.style.paddingTop = '0';
+        var h = prompts.querySelector('h3'); if (h) h.remove();
+        slot.appendChild(prompts);
+        document.getElementById('clusterlane-set-prompts').hidden = false;
+    }
+
+    // Title Display: a live example under the select (0.98.130). Uses a title
+    // from the site when one has the chosen separator, else a made-up one.
+    (function () {
+        var sel = document.getElementById('clusterlane_title_truncation'), custom = document.getElementById('clusterlane_title_trunc_custom');
+        var out = document.getElementById('clusterlane-title-preview');
+        if (!sel || !out) return;
+        var d = out.dataset, titles = [];
+        try { titles = JSON.parse(d.samples || '[]'); } catch (e) {}
+        var total = parseInt(d.total, 10) || 0;
+        var FALLBACK = { colon: 'Skansen: Open-Air Museum & Zoo', dash: 'Skansen – Open-Air Museum & Zoo', pipe: 'Skansen | Open-Air Museum & Zoo', none: 'Skansen: Open-Air Museum & Zoo', custom: 'Skansen: Open-Air Museum & Zoo' };
+        function cut(t, mode) {
+            var r = t;
+            if (mode === 'colon') r = t.replace(/\s*:.*$/, '');
+            else if (mode === 'dash') r = t.replace(/(\s*[\u2013\u2014]|\s+-\s).*$/, '');
+            else if (mode === 'pipe') r = t.replace(/\s*\|.*$/, '');
+            else if (mode === 'custom') {
+                var m = (custom ? custom.value.trim() : '').match(/^\/(.+)\/([gimsuy]*)$/);
+                if (!m) return null;
+                try { r = t.replace(new RegExp(m[1], m[2]), ''); } catch (e) { return null; }
+            }
+            r = r.trim();
+            return r || t;
+        }
+        function line(label, from, to) {
+            out.textContent = '';
+            var b = document.createElement('span'); b.className = 'clusterlane-title-preview__label'; b.textContent = label + ' ';
+            // Show the full title with the removed part struck through.
+            var f = document.createElement('span'); f.className = 'clusterlane-title-preview__from';
+            var i = from.indexOf(to);
+            if (i === 0) {
+                f.append(document.createTextNode(to));
+                var del = document.createElement('del'); del.textContent = from.slice(to.length); f.append(del);
+            } else {
+                var all = document.createElement('del'); all.textContent = from; f.append(all);
+            }
+            var a = document.createElement('span'); a.className = 'clusterlane-title-preview__arrow'; a.textContent = ' → ';
+            var t = document.createElement('strong'); t.textContent = to;
+            out.append(b, f, a, t);
+        }
+        function note(text) {
+            var n = document.createElement('span'); n.className = 'clusterlane-title-preview__note'; n.textContent = text;
+            out.append(n);
+        }
+        function render() {
+            var mode = sel.value;
+            if (mode === 'none') {
+                out.textContent = '';
+                var b = document.createElement('span'); b.className = 'clusterlane-title-preview__label'; b.textContent = d.lSame + ' ';
+                var f = document.createElement('span'); f.className = 'clusterlane-title-preview__same'; f.textContent = titles[0] || FALLBACK.none;
+                out.append(b, f);
+                return;
+            }
+            if (cut(FALLBACK[mode], mode) === null) { out.textContent = d.lBad; return; }
+            var hits = titles.filter(function (t) { return cut(t, mode) !== t; });
+            if (hits.length) line(d.lYours, hits[0], cut(hits[0], mode));
+            else if (mode === 'custom') { out.textContent = d.lNomatch; return; }
+            else line(d.lExample, FALLBACK[mode], cut(FALLBACK[mode], mode));
+            if (mode !== 'custom' && total) {
+                note(!hits.length ? d.lNone
+                    : hits.length === 1 ? d.lCountOne.replace('%d', total)
+                    : d.lCount.replace('%1$d', hits.length).replace('%2$d', total));
+            }
+        }
+        sel.addEventListener('change', render);
+        if (custom) custom.addEventListener('input', render);
+        render();
+    })();
+
+    function show(name, focus) {
+        if (!NAMES[name]) name = 'general';
+        tabs.forEach(function (t) { var on = t.dataset.tab === name; t.classList.toggle('is-active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); t.tabIndex = on ? 0 : -1; });
+        panels.forEach(function (p) { p.hidden = p.dataset.tab !== name; });
+        if (field) field.value = name;
+        try { var u = new URL(location.href); u.searchParams.set('tab', name); u.searchParams.delete('settings-updated'); u.searchParams.delete('recomputed'); history.replaceState(null, '', u); } catch (e) {}
+        if (focus) { var t = tabs.filter(function (x) { return x.dataset.tab === name; })[0]; if (t) t.focus(); }
+    }
+    tabs.forEach(function (t, i) {
+        t.addEventListener('click', function () { show(t.dataset.tab); });
+        t.addEventListener('keydown', function (e) {
+            if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
+            var n = tabs[(i + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
+            show(n.dataset.tab, true);
+        });
+    });
+    show(new URLSearchParams(location.search).get('tab') || (field && field.value) || 'general');
+
+    // Snapshot of every field (name → values), to compare against.
+    function snapshot() {
+        var map = {};
+        Array.prototype.forEach.call(form.elements, function (el) {
+            if (!el.name || el.name === 'clusterlane_tab' || el.name === '_wpnonce' || el.name === '_wp_http_referer' || el.type === 'submit' || el.type === 'button') return;
+            var v = (el.type === 'checkbox' || el.type === 'radio') ? (el.checked ? el.value : '') : el.value;
+            (map[el.name] = map[el.name] || []).push(v);
+        });
+        Object.keys(map).forEach(function (k) { map[k] = map[k].join('\u0001'); });
+        return map;
+    }
+    // Other scripts (link card builder, provider/model sync) fill in fields
+    // after load — take the baseline only once the page has settled.
+    var initial = null;
+    function baseline() { initial = snapshot(); refresh(); }
+    if (document.readyState === 'complete') setTimeout(baseline, 400);
+    else window.addEventListener('load', function () { setTimeout(baseline, 400); });
+    var bar = document.getElementById('clusterlane-set-savebar'), count = document.getElementById('clusterlane-set-changes'), where = document.getElementById('clusterlane-set-where');
+
+    function tabOf(name) {
+        var el = form.querySelector('[name="' + CSS.escape(name) + '"]');
+        var p = el && el.closest('.clusterlane-set-panel');
+        return p ? p.dataset.tab : null;
+    }
+    function refresh() {
+        if (!initial) return;
+        var now = snapshot(), changed = [];
+        Object.keys(Object.assign({}, initial, now)).forEach(function (k) { if ((initial[k] || '') !== (now[k] || '')) changed.push(k); });
+        var perTab = {};
+        changed.forEach(function (k) { var t = tabOf(k); if (t) perTab[t] = (perTab[t] || 0) + 1; });
+        tabs.forEach(function (t) { var em = t.querySelector('em'); if (em) em.textContent = perTab[t.dataset.tab] ? '•' : ''; });
+        bar.hidden = changed.length === 0;
+        count.textContent = changed.length + (changed.length === 1 ? ' unsaved change' : ' unsaved changes');
+        var list = Object.keys(perTab).map(function (t) { return NAMES[t]; });
+        where.textContent = list.length ? 'in ' + list.join(', ') + '.' : '';
+    }
+    var timer;
+    function later() { clearTimeout(timer); timer = setTimeout(refresh, 60); }
+    form.addEventListener('input', later);
+    form.addEventListener('change', later);
+    form.addEventListener('click', later);   // "Reset to default", template add/remove …
+    new MutationObserver(later).observe(form, { childList: true, subtree: true });
+
+    document.getElementById('clusterlane-set-discard').addEventListener('click', function () {
+        form.dataset.submitting = '1';   // no leave-warning for this reload
+        location.reload();
+    });
+    // Read the published pages (0.98.177): only for post types that are scanned.
+    function syncRendered() {
+        form.querySelectorAll('input[name="clusterlane[rendered_types][]"]').forEach(function (cb) {
+            var pt = form.querySelector('input[name="clusterlane[post_types][]"][value="' + cb.value + '"]');
+            var on = !!(pt && pt.checked);
+            cb.disabled = !on;
+            if (!on) cb.checked = false;
+        });
+    }
+    form.addEventListener('change', function (e) {
+        if (e.target && e.target.name === 'clusterlane[post_types][]') syncRendered();
+    });
+
+    form.addEventListener('submit', function () { form.dataset.submitting = '1'; });
+    window.addEventListener('beforeunload', function (e) {
+        if (!bar.hidden && !form.dataset.submitting) { e.preventDefault(); e.returnValue = ''; }
+    });
+})();
Index: /clusterlane/tags/1.3.2/admin/js/text-match.js
===================================================================
--- /clusterlane/tags/1.3.2/admin/js/text-match.js	(revision 3751027)
+++ /clusterlane/tags/1.3.2/admin/js/text-match.js	(revision 3751027)
@@ -0,0 +1,555 @@
+/* Clusterlane — Text Match Scanner
+ *
+ * Finds article titles written in other articles without a link, and lets the
+ * editor link them one by one (or per article) — nothing changes until Apply.
+ *
+ * State is a small model (groups → matches) that is rendered in one place:
+ *   group  = the article where the link would be inserted ("In Skansen")
+ *   match  = one mention: the sentence, the exact words that get linked,
+ *            and the target article with its role (orphans are sorted first:
+ *            linking to an orphan is the most valuable fix).
+ * Apply → the row turns green with Undo for a few seconds.
+ */
+(function () {
+    'use strict';
+
+    if (typeof clusterlaneData === 'undefined') return;
+
+    // v3: snippets saved before 0.98.076 could show "&amp;" and mis-marked words — start fresh.
+    var STORAGE_KEY = 'clusterlane_tms_results_v3';
+    try { localStorage.removeItem('clusterlane_tms_results_v2'); localStorage.removeItem('clusterlane_tms_results_v1'); } catch (e) {}
+    var MAX_AGE_MS  = 7 * 24 * 60 * 60 * 1000;
+    var LINKED_KEEP = 8000;                 // how long "Linked · Undo" stays
+
+    var state   = { groups: [], filter: 'all', skipped: parseInt(clusterlaneData.skipped, 10) || 0, scanned: false };
+    var allIds  = [], currentIdx = 0, running = false;
+
+    function qs(s) { return document.querySelector(s); }
+    function esc(s) {
+        return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+    }
+    function thumb(url, title, role, size) { return typeof window.clusterlaneThumb === 'function' ? window.clusterlaneThumb(url, title, role, size) : ''; }
+    function glyph(n) { return typeof clusterlaneGlyph === 'function' ? clusterlaneGlyph(n) : ''; }
+    function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
+    var canManage = parseInt(clusterlaneData.canManage, 10) === 1;
+
+    function api(path, body, method) {
+        return fetch(clusterlaneData.restUrl + path, {
+            method: method || 'POST',
+            headers: { 'X-WP-Nonce': clusterlaneData.nonce, 'Content-Type': 'application/json' },
+            credentials: 'same-origin',
+            body: method === 'GET' ? undefined : JSON.stringify(body || {}),
+        }).then(function (r) {
+            return r.json().catch(function () { return {}; }).then(function (data) {
+                if (!r.ok) { var err = new Error((data && data.error) || ('HTTP ' + r.status)); err.status = r.status; err.data = data || {}; throw err; }
+                return data;
+            });
+        });
+    }
+
+    function setStatus(msg) { var el = qs('#clusterlane-tms-status'); if (el) el.textContent = msg || ''; }
+
+    /* ------------------------------------------------------------------
+       Category selects
+    ------------------------------------------------------------------ */
+    (clusterlaneData.categories || []).forEach(function (c) {
+        ['clusterlane-tms-scan-cat', 'clusterlane-tms-match-cat'].forEach(function (id) {
+            var sel = document.getElementById(id);
+            if (!sel) return;
+            var o = document.createElement('option');
+            o.value = c.id; o.textContent = c.name + ' (' + c.count + ')';
+            sel.appendChild(o);
+        });
+    });
+
+    /* ------------------------------------------------------------------
+       Model helpers
+    ------------------------------------------------------------------ */
+    var ROLE_LABEL = { orphan: 'Orphan', light: 'Light', support: 'Support', pillar: 'Pillar' };
+
+    function openMatches(g) { return g.matches.filter(function (m) { return m.status === 'open' || m.status === 'busy'; }); }
+    function visible(m) { return m.status !== 'skipped' && m.status !== 'done' && (state.filter === 'all' || m.status === 'linked' || m.target_role === 'orphan'); }
+    function totals() {
+        var t = { open: 0, orphan: 0, articles: 0 };
+        state.groups.forEach(function (g) {
+            var o = openMatches(g);
+            if (o.length) t.articles++;
+            t.open += o.length;
+            t.orphan += o.filter(function (m) { return m.target_role === 'orphan'; }).length;
+        });
+        return t;
+    }
+    function sortModel() {
+        var rank = function (m) { return m.target_role === 'orphan' ? 0 : 1; };
+        state.groups.forEach(function (g) {
+            g.matches.sort(function (a, b) { return rank(a) - rank(b) || (a.target_inbound || 0) - (b.target_inbound || 0); });
+        });
+        state.groups.sort(function (a, b) {
+            var ao = openMatches(a).filter(function (m) { return m.target_role === 'orphan'; }).length;
+            var bo = openMatches(b).filter(function (m) { return m.target_role === 'orphan'; }).length;
+            return (bo - ao) || (openMatches(b).length - openMatches(a).length) || a.post_title.localeCompare(b.post_title);
+        });
+    }
+    function updateTarget(t) {
+        if (!t || !t.id) return;
+        state.groups.forEach(function (g) {
+            g.matches.forEach(function (m) {
+                if (parseInt(m.target_id, 10) === t.id) { m.target_role = t.role; m.target_inbound = t.inbound; }
+            });
+        });
+    }
+
+    /* ------------------------------------------------------------------
+       Rendering
+    ------------------------------------------------------------------ */
+    function sentenceHtml(m) {
+        // Positions are in code points (server: mb_strlen) — slice by code
+        // points too, so emoji before the match don't shift the marking.
+        var cp = Array.from(String(m.snippet || ''));
+        var at = parseInt(m.hit_at, 10), len = parseInt(m.hit_len, 10);
+        if (!(at >= 0 && len > 0 && at + len <= cp.length)) {          // older saved results: first occurrence
+            var needle = Array.from(String(m.match_text || '').toLowerCase());
+            var hay = cp.map(function (c) { return c.toLowerCase(); });
+            at = -1; len = needle.length;
+            for (var i = 0; len && i + len <= hay.length; i++) {
+                if (hay.slice(i, i + len).join('') === needle.join('')) { at = i; break; }
+            }
+        }
+        if (at < 0) return esc(cp.join(''));
+        return esc(cp.slice(0, at).join('')) + '<span class="clusterlane-tm-hit">' + esc(cp.slice(at, at + len).join('')) + '</span>' + esc(cp.slice(at + len).join(''));
+    }
+
+    // Roles without the "add links" permission can review and skip, not link.
+    var CAN_LINK = typeof window.clusterlaneCan !== 'function' || window.clusterlaneCan('links');
+
+    function rowHtml(g, m, i) {
+        var id = 'clusterlane-tm-' + g.post_id + '-' + i;
+        if (m.status === 'linked') {
+            return '<div class="clusterlane-tm-row clusterlane-tm-row--linked" id="' + id + '">'
+                + '<div class="clusterlane-tm-row-main"><p class="clusterlane-tm-done">' + glyph('check') + ( String(m.match_text).toLowerCase() === String(m.target_title).toLowerCase()
+                    ? 'Now links to <strong>' + esc(m.target_title) + '</strong>'
+                    : 'Linked <strong>' + esc(m.match_text) + '</strong> to <strong>' + esc(m.target_title) + '</strong>' ) + '</p>'
+                + (m.warning ? '<p class="clusterlane-tm-note">' + esc(m.warning) + '</p>' : '') + '</div>'
+                + (CAN_LINK ? '<div class="clusterlane-tm-row-actions"><button type="button" class="clusterlane-tm-btn clusterlane-tm-undo" data-g="' + g.post_id + '" data-i="' + i + '">Undo</button></div>' : '')
+                + '</div>';
+        }
+        var role = m.target_role && ROLE_LABEL[m.target_role]
+            ? ' <span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--' + esc(m.target_role) + '"></span>' + ROLE_LABEL[m.target_role] + '</span>'
+            : '';
+        var inbound = m.target_role === 'orphan'
+            ? ' <span class="clusterlane-tm-note">no other article links to it yet</span>'
+            : (m.target_inbound != null ? ' <span class="clusterlane-tm-note">' + plural(m.target_inbound, 'inbound link', 'inbound links') + '</span>' : '');
+        var busy = m.status === 'busy';
+        return '<div class="clusterlane-tm-row' + (m.target_role === 'orphan' ? ' clusterlane-tm-row--orphan' : '') + (m.error ? ' clusterlane-tm-row--failed' : '') + '" id="' + id + '">'
+            + '<div class="clusterlane-tm-row-main">'
+            + '<p class="clusterlane-tm-sentence">' + sentenceHtml(m) + '</p>'
+            + '<p class="clusterlane-tm-target">' + glyph('arrow') + 'link to ' + thumb(m.target_thumb || '', m.target_title, m.target_role, 'xs') + '<strong>' + esc(m.target_title) + '</strong>' + role + inbound + '</p>'
+            + (m.error ? '<p class="clusterlane-tm-error">' + glyph('alert') + esc(m.error) + '</p>' : '')
+            + '</div>'
+            + '<div class="clusterlane-tm-row-actions">'
+            + (CAN_LINK ? '<button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply clusterlane-tm-apply" data-g="' + g.post_id + '" data-i="' + i + '"' + (busy ? ' disabled' : '') + '>' + (busy ? 'Linking…' : 'Apply') + '</button>' : '')
+            + '<button type="button" class="clusterlane-tm-btn clusterlane-tm-skip" data-g="' + g.post_id + '" data-i="' + i + '"' + (busy ? ' disabled' : '') + '>Skip</button>'
+            + '</div></div>';
+    }
+
+    function render() {
+        var results = qs('#clusterlane-tms-results');
+        var preview = qs('#clusterlane-tm-preview');
+        var summary = qs('#clusterlane-tm-summary');
+        var t = totals();
+        var anyRows = state.groups.some(function (g) { return g.matches.some(function (m) { return m.status === 'open' || m.status === 'busy' || m.status === 'linked'; }); });
+
+        if (preview) preview.style.display = (state.scanned || anyRows) ? 'none' : '';
+        if (summary) summary.style.display = (anyRows || bulk || (lastRun && lastRun.links.length)) ? '' : 'none';
+
+        renderBulk(t);
+        var st = qs('#clusterlane-tm-summary-text');
+        if (st) st.innerHTML = t.open
+            ? '<strong>' + plural(t.open, 'mention', 'mentions') + '</strong> in ' + plural(t.articles, 'article', 'articles')
+              + (t.orphan ? ' · <span class="clusterlane-tm-orphan-count">' + t.orphan + ' would rescue an orphan</span>' : '')
+            : 'All mentions handled.';
+        var chips = qs('#clusterlane-tm-filter');
+        if (chips) {
+            chips.querySelector('[data-f="all"] em').textContent = t.open;
+            chips.querySelector('[data-f="orphan"] em').textContent = t.orphan;
+            chips.querySelectorAll('button').forEach(function (b) {
+                var on = b.dataset.f === state.filter;
+                b.classList.toggle('is-active', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
+            });
+        }
+
+        // Long lists in pages of 25 articles (0.98.187): 215 mentions made one 25 000 px page.
+        var shown = 0, hidden = 0, limit = state.limit || 25;
+        var html = state.groups.map(function (g) {
+            var rows = g.matches.map(function (m, i) { return visible(m) ? rowHtml(g, m, i) : ''; }).join('');
+            if (!rows) return '';
+            if (shown >= limit) { hidden++; return ''; }
+            shown++;
+            var open = openMatches(g).filter(visible).length;
+            return '<section class="clusterlane-tm-group">'
+                + '<div class="clusterlane-tm-group-head">'
+                + '<span class="clusterlane-tm-group-src">' + thumb(g.thumb || (g.matches[0] && g.matches[0].source_thumb) || '', g.post_title, '', 'sm') + 'In <a href="' + esc(g.edit_url) + '" target="_blank" rel="noopener"><strong>' + esc(g.post_title) + '</strong></a></span>'
+                + '<span class="clusterlane-tm-group-count">' + plural(open, 'mention', 'mentions') + '</span>'
+                + (open > 1 && CAN_LINK ? '<button type="button" class="clusterlane-tm-btn clusterlane-tm-apply-all" data-g="' + g.post_id + '"' + (g.matches.some(function (m) { return m.status === 'busy'; }) ? ' disabled' : '') + '>Apply all (' + open + ')</button>' : '')
+                + '</div>' + rows + '</section>';
+        }).join('');
+        if (hidden) html += '<p class="clusterlane-tm-more"><button type="button" class="clusterlane-tm-btn" id="clusterlane-tm-more">Show ' + Math.min(25, hidden) + ' more ' + (Math.min(25, hidden) === 1 ? 'article' : 'articles') + '</button> <span class="clusterlane-tm-note">' + plural(hidden, 'article', 'articles') + ' not shown yet · Apply all covers them too</span></p>';
+
+        if (!html && state.scanned && !running) {
+            html = '<div class="clusterlane-tms-empty-state"><span class="clusterlane-tms-empty-icon">' + glyph('check') + '</span>'
+                + (state.groups.length ? '<strong>All done!</strong><p>Every mention is linked or skipped. Run a new search to check for fresh opportunities.</p>'
+                                       : '<strong>No unlinked mentions found.</strong><p>Your articles already link to the titles they mention' + (state.filter === 'orphan' ? ' — or none of them point to an orphan.' : '.') + '</p>')
+                + '</div>';
+        }
+        if (html && !CAN_LINK && typeof window.clusterlaneNoRightNote === 'function') html = window.clusterlaneNoRightNote('links') + html;
+        if (results) results.innerHTML = html;
+        renderSkipped();
+    }
+
+    function renderSkipped() {
+        var b = qs('#clusterlane-tms-clear-skipped');
+        if (!b) return;
+        b.style.display = (canManage && state.skipped > 0 && !running) ? '' : 'none';
+        b.textContent = 'Show ' + plural(state.skipped, 'skipped mention', 'skipped mentions') + ' again';
+    }
+
+    /* ------------------------------------------------------------------
+       Persistence (open matches only)
+    ------------------------------------------------------------------ */
+    function save() {
+        try {
+            var groups = state.groups.map(function (g) {
+                return { post_id: g.post_id, post_title: g.post_title, edit_url: g.edit_url, thumb: g.thumb || '',
+                         matches: g.matches.filter(function (m) { return m.status === 'open' || m.status === 'busy'; })
+                                  .map(function (m) { var c = Object.assign({}, m); c.status = 'open'; return c; }) };
+            }).filter(function (g) { return g.matches.length; });
+            if (groups.length) localStorage.setItem(STORAGE_KEY, JSON.stringify({ ts: Date.now(), groups: groups }));
+            else localStorage.removeItem(STORAGE_KEY);
+        } catch (e) {}
+    }
+    function clearSaved() { try { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem('clusterlane_tms_results_v1'); } catch (e) {} }
+
+    (function offerRestore() {
+        try {
+            var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
+            if (!saved || !saved.groups || !saved.groups.length) return;
+            if (Date.now() - saved.ts > MAX_AGE_MS) { clearSaved(); return; }
+            var mins = Math.round((Date.now() - saved.ts) / 60000);
+            var age  = mins < 60 ? mins + ' min ago' : Math.round(mins / 60) + ' h ago';
+            var n    = saved.groups.reduce(function (s, g) { return s + g.matches.length; }, 0);
+            var banner = document.createElement('div');
+            banner.className = 'clusterlane-tm-restore';
+            banner.innerHTML = '<span>' + glyph('clipboard') + 'You have <strong>' + plural(n, 'open mention', 'open mentions') + '</strong> from a search ' + esc(age) + '.</span>'
+                + '<span><button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" id="clusterlane-tm-restore">Continue</button> '
+                + '<button type="button" class="clusterlane-tm-btn" id="clusterlane-tm-discard">Discard</button></span>';
+            var results = qs('#clusterlane-tms-results');
+            results.parentNode.insertBefore(banner, qs('#clusterlane-tm-summary'));
+            banner.querySelector('#clusterlane-tm-restore').addEventListener('click', function () {
+                banner.remove();
+                state.groups = saved.groups.map(function (g) {
+                    g.matches.forEach(function (m) { m.status = 'open'; });
+                    return g;
+                });
+                state.scanned = true; sortModel(); render();
+                var qq = qs('#clusterlane-tms-clear'); if (qq) qq.style.display = '';
+            });
+            banner.querySelector('#clusterlane-tm-discard').addEventListener('click', function () { clearSaved(); banner.remove(); });
+        } catch (e) {}
+    })();
+
+    /* ------------------------------------------------------------------
+       Scan
+    ------------------------------------------------------------------ */
+    var startBtn = qs('#clusterlane-tms-start'), stopBtn = qs('#clusterlane-tms-stop'), clearBtn = qs('#clusterlane-tms-clear');
+
+    function setStartLabel(txt) { var l = startBtn && startBtn.querySelector('.clusterlane-action-btn__label'); if (l) l.textContent = txt; }
+    function lockFilters(on) { ['clusterlane-tms-scan-cat', 'clusterlane-tms-match-cat'].forEach(function (id) { var el = document.getElementById(id); if (el) el.disabled = on; }); }
+    function progress(done, total) {
+        var bar = qs('#clusterlane-tms-bar'), lbl = qs('#clusterlane-tms-progress-label');
+        if (bar) bar.style.width = (total ? Math.round(done / total * 100) : 0) + '%';
+        if (lbl) lbl.textContent = done + ' / ' + total + ' articles searched';
+    }
+
+    startBtn && startBtn.addEventListener('click', function () {
+        if (running) return;
+        var resuming = allIds.length && currentIdx > 0 && currentIdx < allIds.length;
+        if (!resuming) {
+            if (totals().open && !confirm('Start a new search? Current results will be replaced.')) return;
+            var rb = qs('.clusterlane-tm-restore'); if (rb) rb.remove();
+            state.groups = []; state.filter = 'all'; state.limit = 25; currentIdx = 0; allIds = []; clearSaved();
+        }
+        running = true; state.scanned = true;
+        startBtn.disabled = true; stopBtn.style.display = ''; clearBtn.style.display = 'none';
+        qs('#clusterlane-tms-progress-wrap').style.display = '';
+        lockFilters(true); render();
+        if (resuming) { scanNext(); return; }
+
+        setStatus('Loading articles…');
+        var scanCat = parseInt((document.getElementById('clusterlane-tms-scan-cat') || {}).value, 10) || 0;
+        api('tms/posts' + (scanCat > 0 ? '?scan_cat=' + scanCat : ''), null, 'GET').then(function (res) {
+            state.skipped = parseInt(res.skipped, 10) || 0;
+            if (!res.ids || !res.ids.length) { setStatus('No articles in this category.'); finish(); return; }
+            allIds = res.ids; scanNext();
+        }).catch(function (e) { setStatus(e.message || 'Could not load articles.'); finish(); });
+    });
+
+    stopBtn && stopBtn.addEventListener('click', function () {
+        running = false;
+        stopBtn.style.display = 'none'; clearBtn.style.display = '';
+        startBtn.disabled = false; setStartLabel('Resume search');
+        lockFilters(false);
+        setStatus('Paused after ' + currentIdx + ' of ' + allIds.length + ' articles.');
+        sortModel(); render(); save();
+    });
+
+    clearBtn && clearBtn.addEventListener('click', function () {
+        state.groups = []; state.scanned = false; state.filter = 'all';
+        allIds = []; currentIdx = 0; clearSaved();
+        qs('#clusterlane-tms-progress-wrap').style.display = 'none';
+        clearBtn.style.display = 'none'; setStartLabel('Find unlinked mentions'); setStatus('');
+        render();
+    });
+
+    // Many articles per request, time-boxed on the server (tms/scan-batch);
+    // the server says how many it got through and we continue from there.
+    function scanNext() {
+        if (!running) return;
+        if (currentIdx >= allIds.length) { finish(); return; }
+        progress(currentIdx, allIds.length);
+        setStatus('Searching ' + (currentIdx + 1) + ' of ' + allIds.length + '…');
+        var matchCat = parseInt((document.getElementById('clusterlane-tms-match-cat') || {}).value, 10) || 0;
+        var chunk = allIds.slice(currentIdx, currentIdx + 200);
+        var settled = false;
+        var guard = setTimeout(function () { if (settled) return; settled = true; currentIdx++; setTimeout(scanNext, 40); }, 45000);
+
+        api('tms/scan-batch', { ids: chunk, match_cat: matchCat }).then(function (res) {
+            if (settled) return; settled = true; clearTimeout(guard);
+            (res && res.results || []).forEach(function (g) {
+                if (!g.matches || !g.matches.length) return;
+                state.groups.push({
+                    post_id: g.post_id, post_title: g.post_title, thumb: (g.matches[0] && g.matches[0].source_thumb) || '',
+                    edit_url: (clusterlaneData.adminUrl || '/wp-admin/') + 'post.php?action=edit&post=' + g.post_id,
+                    matches: g.matches.map(function (m) { m.status = 'open'; return m; }),
+                });
+            });
+            currentIdx += Math.max(1, (res && res.processed) || 0);
+            progress(currentIdx, allIds.length);
+            render(); save();
+            setTimeout(scanNext, 40);
+        }).catch(function () { if (settled) return; settled = true; clearTimeout(guard); currentIdx++; setTimeout(scanNext, 40); });
+    }
+
+    function finish() {
+        running = false;
+        startBtn.disabled = false; setStartLabel('Find unlinked mentions');
+        stopBtn.style.display = 'none'; clearBtn.style.display = state.groups.length ? '' : 'none';
+        lockFilters(false);
+        progress(allIds.length, allIds.length);
+        var t = totals();
+        setStatus(allIds.length ? 'Searched ' + plural(allIds.length, 'article', 'articles') + '.' : '');
+        if (!t.open) setStatus(allIds.length ? 'Searched ' + plural(allIds.length, 'article', 'articles') + ' — nothing to link.' : '');
+        allIds = []; currentIdx = 0;
+        sortModel(); render(); save();
+    }
+
+    /* ------------------------------------------------------------------
+       Actions
+    ------------------------------------------------------------------ */
+    function find(gid, i) {
+        var g = state.groups.find(function (x) { return String(x.post_id) === String(gid); });
+        return g ? { g: g, m: g.matches[parseInt(i, 10)] } : null;
+    }
+
+    function applyOne(g, m, quiet) {
+        m.status = 'busy'; delete m.error; if (!quiet) render();
+        return api('tms/apply', { source_id: parseInt(m.source_id, 10), match_text: m.match_text, target_url: m.target_url, quick: !!quiet })
+            .then(function (res) {
+                m.status = 'linked';
+                updateTarget(res.target);
+                if (res.warning) m.warning = res.warning;
+                setTimeout(function () { if (m.status === 'linked') { m.status = 'done'; if (!bulk) render(); } }, LINKED_KEEP);
+                if (!quiet) { render(); save(); }
+            })
+            .catch(function (e) {
+                m.status = 'open';
+                if (quiet) throw e;
+                render(); alert(e.message || 'Could not add the link.'); throw e;
+            });
+    }
+
+    /* ------------------------------------------------------------------
+       Apply all mentions on the list (0.98.184)
+       One after another, with progress and Stop. A mention that can't be
+       linked because the article changed — the words are gone or already
+       linked, or the page is built with a page builder — is skipped, as an
+       editor would; one that failed for a passing reason (someone is editing
+       the article, the connection dropped) stays on the list with the reason,
+       to try again. Undo run removes every link this run added.
+    ------------------------------------------------------------------ */
+    var bulk = null;     // { queue, done, linked, skipped, failed, stop }
+    var lastRun = null;  // links added by the last run, for Undo run
+
+    function renderBulk(t) {
+        var box = qs('#clusterlane-tm-bulk');
+        if (!box) {
+            var summary = qs('#clusterlane-tm-summary'); if (!summary) return;
+            box = document.createElement('div'); box.id = 'clusterlane-tm-bulk'; box.className = 'clusterlane-tm-bulk';
+            summary.appendChild(box);
+        }
+        var html = '';
+        if (bulk) {
+            html = '<span class="clusterlane-tm-bulk-progress">Linking ' + Math.min(bulk.done + 1, bulk.queue.length) + ' of ' + bulk.queue.length + '…</span>'
+                 + '<button type="button" class="clusterlane-tm-btn" id="clusterlane-tm-bulk-stop">Stop</button>';
+        } else {
+            if (CAN_LINK && t.open > 1 && !running) {
+                // Secondary on purpose (0.98.187): the list itself is the main thing; Apply all is a deliberate step.
+                html += '<button type="button" class="clusterlane-tm-btn" id="clusterlane-tm-bulk-apply">Apply all ' + (state.filter === 'orphan' ? 'to orphans ' : '') + '(' + (state.filter === 'orphan' ? t.orphan : t.open) + ')</button>';
+            }
+            if (lastRun && lastRun.links.length) {
+                html += '<button type="button" class="clusterlane-tm-btn" id="clusterlane-tm-bulk-undo">Undo run (' + lastRun.links.length + ')</button>';
+            }
+        }
+        box.innerHTML = html;
+        box.style.display = html ? '' : 'none';
+    }
+
+    var renderTimer = null;
+    function renderSoon() { if (!renderTimer) renderTimer = setTimeout(function () { renderTimer = null; render(); }, 400); }
+
+    // Reasons that won't go away by trying again: the article changed or can't be edited this way.
+    function permanent(err) {
+        if (!err) return false;
+        if (err.status === 422) return true;                                  // the words are gone or already linked
+        if (err.status === 409 && err.data && err.data.page_builder) return true; // built with a page builder: Clusterlane won't write there
+        if (err.status === 404) return true;                                  // the article is gone
+        return false;                                                         // someone editing, no permission, connection: try again later
+    }
+
+    function runBulk() {
+        var queue = [];
+        state.groups.forEach(function (g) {
+            g.matches.forEach(function (m) { if (m.status === 'open' && visible(m)) queue.push({ g: g, m: m }); });
+        });
+        if (!queue.length) return;
+        var arts = {}; queue.forEach(function (q) { arts[q.g.post_id] = true; });
+        if (!confirm('Add ' + plural(queue.length, 'link', 'links') + ' in ' + plural(Object.keys(arts).length, 'article', 'articles') + '?\n\nMentions that can no longer be linked are skipped. You can undo the whole run afterwards.')) return;
+        bulk = { queue: queue, done: 0, linked: 0, skipped: 0, failed: 0, stop: false };
+        lastRun = { links: [] };
+        render();
+        (function next() {
+            if (!bulk) return;
+            if (bulk.stop || bulk.done >= bulk.queue.length) { endBulk(); return; }
+            var q = bulk.queue[bulk.done];
+            if (q.m.status !== 'open') { bulk.done++; setTimeout(next, 0); return; } // handled meanwhile
+            applyOne(q.g, q.m, true).then(function () {
+                bulk.linked++; lastRun.links.push(q.m);
+            }).catch(function (e) {
+                if (permanent(e)) {
+                    q.m.status = 'skipped'; state.skipped++; bulk.skipped++;
+                    api('tms/skip', { source_id: parseInt(q.m.source_id, 10), target_id: parseInt(q.m.target_id, 10) }).catch(function () {});
+                } else {
+                    q.m.error = (e && e.message) || 'Could not add the link.'; bulk.failed++;
+                }
+            }).then(function () {
+                bulk.done++;
+                setStatus('Linked ' + bulk.linked + (bulk.skipped ? ' · skipped ' + bulk.skipped : '') + (bulk.failed ? ' · ' + bulk.failed + ' to try again' : '') + ' — ' + bulk.done + ' of ' + bulk.queue.length + '.');
+                renderSoon();
+                setTimeout(next, 30);
+            });
+        })();
+    }
+
+    // The run skipped re-reading each target; Metrics recounts every article's links (and cluster figures).
+    function refreshMetrics(msg) {
+        setStatus(msg + ' Updating cluster figures…');
+        return api('clusters/compute').then(function () { setStatus(msg + ' Cluster figures updated.'); })
+            .catch(function () { setStatus(msg + ' Click Scan in the bar at the top to update the cluster figures.'); });
+    }
+
+    function endBulk() {
+        var b = bulk; bulk = null;
+        state.groups.forEach(function (g) { g.matches.forEach(function (m) { if (m.status === 'linked') m.status = 'done'; }); });
+        render(); save();
+        refreshMetrics((b.stop ? 'Stopped. ' : 'Done. ') + plural(b.linked, 'link', 'links') + ' added'
+            + (b.skipped ? ', ' + b.skipped + ' skipped (the words had changed, were already linked, or the article is built with a page builder)' : '')
+            + (b.failed ? ', ' + b.failed + ' not linked — the reason is on each; Apply to try again' : '') + '.');
+    }
+
+    function undoRun() {
+        if (!lastRun || !lastRun.links.length) return;
+        if (!confirm('Remove the ' + plural(lastRun.links.length, 'link', 'links') + ' this run added?')) return;
+        var list = lastRun.links.slice(); lastRun = null;
+        var n = 0, bad = 0;
+        bulk = { queue: list, done: 0, linked: 0, skipped: 0, failed: 0, stop: false }; render();
+        (function next() {
+            if (!list.length) { bulk = null; render(); save(); refreshMetrics('Undone: ' + plural(n, 'link', 'links') + ' removed' + (bad ? ', ' + bad + ' could not be found any more' : '') + '.'); return; }
+            var m = list.shift();
+            api('tms/unlink', { source_id: parseInt(m.source_id, 10), match_text: m.match_text, target_url: m.target_url, quick: true })
+                .then(function () { m.status = 'open'; n++; })
+                .catch(function () { bad++; })
+                .then(function () { bulk && bulk.done++; setStatus('Undoing — ' + (n + bad) + ' of ' + (n + bad + list.length) + '…'); renderSoon(); setTimeout(next, 30); });
+        })();
+    }
+
+    document.addEventListener('click', function (e) {
+        if (e.target.closest('#clusterlane-tm-bulk-apply')) runBulk();
+        else if (e.target.closest('#clusterlane-tm-bulk-stop') && bulk) { bulk.stop = true; e.target.disabled = true; e.target.textContent = 'Stopping…'; }
+        else if (e.target.closest('#clusterlane-tm-bulk-undo')) undoRun();
+    });
+    window.addEventListener('beforeunload', function (e) { if (bulk) { e.preventDefault(); e.returnValue = ''; } });
+
+    var chipsEl = qs('#clusterlane-tm-filter');
+    chipsEl && chipsEl.addEventListener('click', function (e) {
+        var b = e.target.closest('button[data-f]'); if (!b) return;
+        state.filter = b.dataset.f; state.limit = 25; render();
+    });
+    document.addEventListener('click', function (e) {
+        if (!e.target.closest('#clusterlane-tm-more')) return;
+        state.limit = (state.limit || 25) + 25; render();
+    });
+
+    document.addEventListener('click', function (e) {
+        var b = e.target.closest('.clusterlane-tm-apply, .clusterlane-tm-skip, .clusterlane-tm-undo, .clusterlane-tm-apply-all');
+        if (!b || b.disabled || !qs('#clusterlane-tms-results').contains(b)) return;
+
+        if (b.classList.contains('clusterlane-tm-apply-all')) {
+            var g = state.groups.find(function (x) { return String(x.post_id) === b.dataset.g; });
+            if (!g) return;
+            var queue = openMatches(g).filter(visible);
+            b.disabled = true;
+            queue.reduce(function (p, m) { return p.then(function () { return applyOne(g, m); }); }, Promise.resolve())
+                 .catch(function () {});
+            return;
+        }
+
+        var hit = find(b.dataset.g, b.dataset.i);
+        if (!hit || !hit.m) return;
+
+        if (b.classList.contains('clusterlane-tm-apply')) { applyOne(hit.g, hit.m).catch(function () {}); return; }
+
+        if (b.classList.contains('clusterlane-tm-skip')) {
+            hit.m.status = 'skipped'; state.skipped++; render(); save();
+            api('tms/skip', { source_id: parseInt(hit.m.source_id, 10), target_id: parseInt(hit.m.target_id, 10) }).catch(function () {});
+            return;
+        }
+
+        if (b.classList.contains('clusterlane-tm-undo')) {
+            b.disabled = true; b.textContent = 'Undoing…';
+            api('tms/unlink', { source_id: parseInt(hit.m.source_id, 10), match_text: hit.m.match_text, target_url: hit.m.target_url })
+                .then(function (res) { hit.m.status = 'open'; updateTarget(res.target); render(); save(); })
+                .catch(function (err) { b.disabled = false; b.textContent = 'Undo'; alert(err.message || 'Could not undo.'); });
+        }
+    });
+
+    var skippedBtn = qs('#clusterlane-tms-clear-skipped');
+    skippedBtn && skippedBtn.addEventListener('click', function () {
+        if (!confirm('Show all ' + state.skipped + ' skipped mentions again on the next search?')) return;
+        skippedBtn.disabled = true;
+        api('tms/skip', null, 'DELETE').then(function () {
+            state.skipped = 0; skippedBtn.disabled = false; renderSkipped();
+            setStatus('Skipped mentions will show up again on the next search.');
+        }).catch(function () { skippedBtn.disabled = false; });
+    });
+
+    render();
+})();
Index: /clusterlane/tags/1.3.2/clusterlane.php
===================================================================
--- /clusterlane/tags/1.3.2/clusterlane.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/clusterlane.php	(revision 3751027)
@@ -0,0 +1,104 @@
+<?php
+/**
+ * Plugin Name:       Clusterlane
+ * Plugin URI:        https://clusterlane.app
+ * Description:       Map your content. Build your authority. Cluster-based SEO intelligence with AI-powered link suggestions and content gap analysis.
+ * Version:           1.3.2
+ * Requires at least: 6.2
+ * Requires PHP:      8.0
+ * Author:            Clusterlane
+ * License:           GPL-2.0-or-later
+ * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
+ * Text Domain:       clusterlane
+ */
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// PHP version guard — must come before any class or syntax that requires 8.0+
+// so that 7.x gets a readable notice instead of a fatal parse error.
+if ( version_compare( PHP_VERSION, '8.0', '<' ) ) {
+    add_action( 'admin_notices', function () {
+        echo '<div class="notice notice-error"><p>'
+            . '<strong>Clusterlane</strong> requires PHP 8.0 or higher. '
+            . 'You are running PHP ' . esc_html( PHP_VERSION ) . '. Please upgrade PHP or contact your host.</p></div>';
+    } );
+    return;
+}
+
+/* ------------------------------------------------------------------
+   One copy at a time (1.2.0)
+
+   Clusterlane Pro installs in its own folder (clusterlane-premium), so the
+   free version and Pro can both be installed. Only one runs: if another
+   copy is already loaded in this request, this one stays out of the way.
+   Both use the same tables and settings, so nothing is lost either way.
+   (Pro's own handling of a free copy lives in its premium folder.)
+------------------------------------------------------------------ */
+
+if ( defined( 'CLUSTERLANE_VERSION' ) ) {
+    if ( is_readable( __DIR__ . '/intelligence/pro__premium_only/coexist.php' ) ) {
+        require __DIR__ . '/intelligence/pro__premium_only/coexist.php';
+    }
+    return;
+}
+
+/* ------------------------------------------------------------------
+   Constants
+------------------------------------------------------------------ */
+
+define( 'CLUSTERLANE_VERSION',   '1.3.2' );
+define( 'CLUSTERLANE_FILE',      __FILE__ );
+define( 'CLUSTERLANE_DIR',       plugin_dir_path( __FILE__ ) );
+define( 'CLUSTERLANE_URL',       plugin_dir_url( __FILE__ ) );
+define( 'CLUSTERLANE_SLUG',      'clusterlane' );
+
+/* ------------------------------------------------------------------
+   Autoloader — maps Clusterlane\Foo\Bar to intelligence/foo/class-bar.php
+------------------------------------------------------------------ */
+
+spl_autoload_register( function ( string $class ): void {
+    if ( strpos( $class, 'Clusterlane\\' ) !== 0 ) return;
+
+    $relative = substr( $class, 12 );                         // strip "Clusterlane\"
+    $parts    = explode( '\\', $relative );
+    $filename = 'class-' . strtolower( str_replace( '_', '-', array_pop( $parts ) ) ) . '.php';
+    $subpath  = implode( DIRECTORY_SEPARATOR, array_map( 'strtolower', $parts ) );
+
+    $file = CLUSTERLANE_DIR
+          . ( $subpath ? $subpath . DIRECTORY_SEPARATOR : '' )
+          . $filename;
+
+    if ( file_exists( $file ) ) require_once $file;
+} );
+
+/* ------------------------------------------------------------------
+   Licensing (Pro build only — the file is not in the free version)
+------------------------------------------------------------------ */
+
+if ( is_readable( CLUSTERLANE_DIR . 'intelligence/pro__premium_only/freemius-init.php' ) ) {
+    require_once CLUSTERLANE_DIR . 'intelligence/pro__premium_only/freemius-init.php';
+}
+
+/* ------------------------------------------------------------------
+   Activation / deactivation / uninstall hooks
+------------------------------------------------------------------ */
+
+register_activation_hook( __FILE__, function( $network_wide = false ) {
+    require_once CLUSTERLANE_DIR . 'intelligence/core/class-multisite.php';
+    // Single site → Installer::activate(); network activation → every site.
+    \Clusterlane\Core\Multisite::activate( (bool) $network_wide );
+} );
+register_deactivation_hook( __FILE__, function( $network_wide = false ) {
+    require_once CLUSTERLANE_DIR . 'intelligence/core/class-multisite.php';
+    // Single site → Installer::deactivate(); network deactivation → every site (0.98.175).
+    \Clusterlane\Core\Multisite::deactivate( (bool) $network_wide );
+} );
+
+/* ------------------------------------------------------------------
+   Boot
+------------------------------------------------------------------ */
+
+add_action( 'plugins_loaded', function (): void {
+    require_once CLUSTERLANE_DIR . 'intelligence/core/class-plugin.php';
+    ( new Clusterlane\Core\Plugin() )->boot();
+} );
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-admin.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-admin.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-admin.php	(revision 3751027)
@@ -0,0 +1,1246 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Admin
+ *
+ * Registers admin menus and enqueues admin assets.
+ */
+class Admin {
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register(): void {
+        add_action( 'admin_menu',            [ $this, 'register_menu'  ] );
+        add_action( 'admin_menu',            [ $this, 'group_menu' ], 999 );
+        add_action( 'admin_enqueue_scripts', [ $this, 'menu_assets' ] );
+        add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
+        add_action( 'admin_init',            [ $this, 'handle_settings_save' ], 1 );
+    }
+
+    public function handle_settings_save(): void {
+        if ( ! isset( $_POST['clusterlane_save_settings'] ) ) return;
+        if ( ! check_admin_referer( 'clusterlane_settings_save' ) ) return;
+        if ( ! \Clusterlane\Core\Capabilities::can_manage() ) return;
+
+        require_once \CLUSTERLANE_DIR . 'intelligence/admin/class-settings-page.php';
+        $page = new \Clusterlane\Admin\SettingsPage( $this->plugin );
+        $page->handle_save();
+
+        $args = [ 'settings-updated' => '1' ];
+        if ( $page->recomputed ) $args['recomputed'] = '1';
+        $tab = sanitize_key( $_POST['clusterlane_tab'] ?? '' );
+        if ( in_array( $tab, [ 'general', 'content', 'styles', 'advanced' ], true ) ) $args['tab'] = $tab;   // come back to the same tab
+        wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php?page=clusterlane-settings' ) ) );
+        exit;
+    }
+
+    public function register_menu(): void {
+        // The station mark (as on clusterlane.app), or the white-label logo — see Brand.
+        $icon_url = \Clusterlane\Core\Brand::menu_icon();
+
+        add_menu_page(
+            \Clusterlane\Core\Brand::name(),
+            \Clusterlane\Core\Brand::name(),
+            \Clusterlane\Core\Capabilities::USE,
+            'clusterlane-dashboard',
+            [ $this, 'render_dashboard' ],
+            $icon_url,
+            30
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Dashboard', 'clusterlane' ), __( 'Dashboard', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-dashboard', [ $this, 'render_dashboard' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Cluster Inspector', 'clusterlane' ), __( 'Cluster Inspector', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-cluster-inspector', [ $this, 'render_cluster_inspector' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Article Inspector', 'clusterlane' ), __( 'Article Inspector', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-article-inspector', [ $this, 'render_article_inspector' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Text Match', 'clusterlane' ), __( 'Text Match', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-text-match', [ $this, 'render_text_match' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Link Suggestions', 'clusterlane' ), __( 'Link Suggestions', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-link-suggest', [ $this, 'render_link_suggest' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'SEO Titles', 'clusterlane' ), __( 'SEO Titles', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-seo-titles', [ $this, 'render_seo_titles' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Report', 'clusterlane' ), __( 'Report', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-report', [ $this, 'render_report' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Cluster Admin', 'clusterlane' ), __( 'Cluster Admin', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-cluster-settings', [ $this, 'render_cluster_settings' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Settings', 'clusterlane' ), __( 'Settings', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-settings', [ $this, 'render_settings' ]
+        );
+
+        add_submenu_page( 'clusterlane-dashboard',
+            __( 'Help', 'clusterlane' ), __( 'Help', 'clusterlane' ),
+            \Clusterlane\Core\Capabilities::USE, 'clusterlane-help', [ $this, 'render_help' ]
+        );
+    }
+
+    /**
+     * Left menu in groups (0.98.146): the pages used most on top, then
+     * TOOLS and ADMIN as small labels, the way WordPress plugins group a long
+     * submenu (submenus have one level — no icons or indents). A label only
+     * shows to users who can open a page under it; items other code added
+     * keep their place at the end.
+     */
+    public function group_menu(): void {
+        global $submenu;
+        if ( empty( $submenu['clusterlane-dashboard'] ) ) return;
+        $items = [];
+        foreach ( $submenu['clusterlane-dashboard'] as $it ) $items[ $it[2] ] = $it;
+        $label = static fn( string $text, string $cap, string $id ) => [ '<span class="clusterlane-menu-group-label">' . esc_html( $text ) . '</span>', $cap, '#' . $id, '', 'clusterlane-menu-group' ];
+        $order = [
+            'clusterlane-dashboard', 'clusterlane-cluster-inspector', 'clusterlane-article-inspector', 'clusterlane-report', 'clusterlane-help',
+            $label( __( 'Tools', 'clusterlane' ), \Clusterlane\Core\Capabilities::USE, 'clusterlane-group-tools' ),
+            'clusterlane-text-match', 'clusterlane-link-suggest', 'clusterlane-seo-titles',
+            $label( __( 'Admin', 'clusterlane' ), \Clusterlane\Core\Capabilities::MANAGE, 'clusterlane-group-admin' ),
+            'clusterlane-cluster-settings', 'clusterlane-settings',
+        ];
+        $out = [];
+        foreach ( $order as $o ) {
+            if ( is_array( $o ) ) { $out[] = $o; continue; }
+            if ( isset( $items[ $o ] ) ) { $out[] = $items[ $o ]; unset( $items[ $o ] ); }
+        }
+        $submenu['clusterlane-dashboard'] = array_merge( $out, array_values( $items ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- reordering our own submenu
+    }
+
+    /**
+     * The group labels in the admin menu (Tools, Admin) — on every admin page,
+     * where the menu is: their style goes with the core menu stylesheet, and
+     * the script that takes them out of the tab order (no href, read as a
+     * group heading) runs in the footer, after the menu.
+     */
+    public function menu_assets(): void {
+        wp_add_inline_style( 'admin-menu',
+            '#adminmenu .clusterlane-menu-group a{pointer-events:none;cursor:default;padding-top:10px !important;padding-bottom:2px !important;box-shadow:none !important}'
+            . '#adminmenu .clusterlane-menu-group-label{font-size:10.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;opacity:.6}'
+            . '#adminmenu .wp-submenu .clusterlane-menu-group a:hover,#adminmenu .wp-submenu .clusterlane-menu-group a:focus{color:inherit;background:none}'
+        );
+        wp_register_script( 'clusterlane-menu', false, [], CLUSTERLANE_VERSION, true );
+        wp_enqueue_script( 'clusterlane-menu' );
+        wp_add_inline_script( 'clusterlane-menu',
+            "document.querySelectorAll('#adminmenu .clusterlane-menu-group a').forEach(function(a){a.removeAttribute('href');a.setAttribute('role','heading');a.setAttribute('aria-level','3');a.tabIndex=-1;});"
+        );
+    }
+
+    /**
+     * admin.min.css (built from admin.css with clean-css, level 1) unless
+     * SCRIPT_DEBUG is on or the minified file is missing — then the source.
+     */
+    private static function admin_css(): string {
+        $min = ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) && is_readable( CLUSTERLANE_DIR . 'admin/css/admin.min.css' );
+        return $min ? 'admin/css/admin.min.css' : 'admin/css/admin.css';
+    }
+
+    /**
+     * Onboarding step 2 (0.98.177): post types with published pages (pre-ticked:
+     * the scanned ones and any with 5+ pages, pages aside), whether their text has
+     * links, and cluster candidates from categories and every other hierarchical
+     * taxonomy of that content (2+ pages each).
+     *
+     * @return array{types: array<int, array{name: string, label: string, count: int, checked: bool, recommended: bool}>, groups: array<int, array{taxonomy: string, label: string, types: string[], terms: array<int, array{id: int, name: string, count: int}>}>, hubs: array<int, array{type: string, singular: string, label: string, pages: int, with: string[], types: string[], checked: bool}>}
+     */
+    private function onboarding_data( \Clusterlane\Core\Settings $settings ): array {
+        $objs = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' );
+        unset( $objs['attachment'] );
+        $counts  = \Clusterlane\Core\PageReader::text_link_density( array_keys( $objs ) );
+        $scanned = $settings->post_types();
+        $types   = [];
+        foreach ( $objs as $name => $o ) {
+            $n = (int) ( $counts[ $name ]['posts'] ?? 0 );
+            if ( ! $n ) continue;
+            $types[] = [
+                'name'        => $name,
+                'label'       => html_entity_decode( (string) $o->labels->name, ENT_QUOTES, 'UTF-8' ),
+                'count'       => $n,
+                'checked'     => in_array( $name, $scanned, true ) || ( $name !== 'page' && $n >= 5 ),
+                'recommended' => \Clusterlane\Core\PageReader::is_recommended( $counts[ $name ] ),
+            ];
+        }
+        usort( $types, fn( $a, $b ) => ( $a['name'] === 'post' ? -1 : ( $b['name'] === 'post' ? 1 : 0 ) ) ?: $b['count'] <=> $a['count'] );
+
+        $groups = [];
+        $taxes  = [ 'category' => get_taxonomy( 'category' ) ] + array_filter( \Clusterlane\Core\ClusterTypes::extra_taxonomies( array_keys( $objs ) ), fn( $t ) => $t->hierarchical );
+        foreach ( $taxes as $tax => $obj ) {
+            if ( ! $obj ) continue;
+            $terms = get_terms( [ 'taxonomy' => $tax, 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 200 ] );
+            if ( is_wp_error( $terms ) ) continue;
+            $list = [];
+            foreach ( $terms as $t ) {
+                if ( (int) $t->count < 2 || ( $tax === 'category' && (int) $t->term_id === (int) get_option( 'default_category' ) && (int) $t->count < 5 ) ) continue;
+                // Pre-ticked (0.98.185): the 15 largest categories with 3+ pages, so
+                // "Save & continue" never gives an empty map by accident.
+                $pick   = $tax === 'category' && (int) $t->count >= 3 && count( array_filter( $list, fn( $x ) => $x['checked'] ) ) < 15;
+                $list[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 'count' => (int) $t->count, 'checked' => $pick ];
+            }
+            if ( ! $list ) continue;
+            usort( $list, fn( $a, $b ) => strcasecmp( $a['name'], $b['name'] ) );
+            $groups[] = [
+                'taxonomy' => $tax,
+                'label'    => html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ),
+                'types'    => array_values( (array) $obj->object_type ),
+                'terms'    => $list,
+            ];
+        }
+        // Two taxonomies both called "Categories" (posts and a custom type):
+        // name the content they belong to, "Categories · Attractions".
+        if ( count( $groups ) > 1 ) {
+            $labels = [];
+            foreach ( $types as $t ) $labels[ $t['name'] ] = $t['label'];
+            foreach ( $groups as &$g ) {
+                $of = array_values( array_filter( array_map( fn( $pt ) => $labels[ $pt ] ?? '', $g['types'] ) ) );
+                if ( $of ) $g['label'] .= ' · ' . implode( ', ', $of );
+            }
+            unset( $g );
+        }
+        // Pages as clusters (0.98.180): post types the others point to through custom fields.
+        $labels = [];
+        foreach ( $types as $t ) $labels[ $t['name'] ] = $t['label'];
+        $hubs  = [];
+        $cands = \Clusterlane\Core\HubClusters::candidates( array_keys( $objs ) );
+        foreach ( $cands as $pt => $c ) {
+            $from = [];
+            foreach ( $c['paths'] as $p ) $from[ $p['from'] ] = true;
+            // Ticked from the start: the top of the hierarchy (islands), not what points to it (areas).
+            $top = true;
+            foreach ( $cands as $other => $oc ) {
+                foreach ( $oc['paths'] as $p ) if ( $other !== $pt && $p['from'] === $pt && ! isset( $p['via'] ) ) $top = false;
+            }
+            $names = array_values( array_filter( array_map( fn( $t ) => $labels[ $t ] ?? '', array_keys( $from ) ) ) );
+            $hubs[] = [ 'type' => $pt, 'singular' => $c['singular'], 'label' => $c['label'], 'pages' => $c['pages'], 'with' => $names, 'types' => array_merge( [ $pt ], array_keys( $from ) ), 'checked' => $top ];
+        }
+        return [ 'types' => $types, 'groups' => $groups, 'hubs' => $hubs ];
+    }
+
+    public function enqueue_assets( string $hook ): void {
+        if ( strpos( $hook, 'clusterlane-' ) === false ) return;
+
+        // Network Admin overview: styles only — the site scripts and their
+        // clusterlaneData belong to a single site's admin, not the network.
+        if ( is_network_admin() ) {
+            wp_enqueue_style( 'clusterlane-admin', CLUSTERLANE_URL . self::admin_css(), [], CLUSTERLANE_VERSION );
+            return;
+        }
+
+        // "?" tooltips — loaded in <head> so every page script can call
+        // window.clusterlaneTip() while rendering. Definitions: clusterlane_tip_definitions().
+        wp_enqueue_script( 'clusterlane-tips', CLUSTERLANE_URL . 'admin/js/clusterlane-tips.js', [], CLUSTERLANE_VERSION, false );
+
+        // Edition (Free/Pro/Agency) — also in <head> so page scripts can ask
+        // clusterlaneHas( 'ai' ). Free gets the example data shown instead of AI output.
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+        wp_enqueue_script( 'clusterlane-edition', CLUSTERLANE_URL . 'admin/js/clusterlane-edition.js', [], CLUSTERLANE_VERSION, false );
+        wp_localize_script( 'clusterlane-edition', 'clusterlaneBrand', \Clusterlane\Core\Brand::to_js() );
+        wp_localize_script( 'clusterlane-edition', 'clusterlaneClusterTypes', \Clusterlane\Core\ClusterTypes::for_js() ); // 0.98.177: labels for any taxonomy
+        wp_localize_script( 'clusterlane-edition', 'clusterlaneEditionData', array_merge( \Clusterlane\Core\Edition::for_js(), [
+            'labels'   => \Clusterlane\Admin\ProExamples::labels(),
+            'can'      => \Clusterlane\Core\Capabilities::for_js() + [
+                'linksNote' => \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::LINKS ),
+                'seoNote'   => \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::SEO ),
+            ],
+            'examples' => \Clusterlane\Core\Edition::has( 'ai' ) ? new \stdClass() : \Clusterlane\Admin\ProExamples::data(),
+        ] ) );
+        wp_localize_script( 'clusterlane-tips', 'clusterlaneTipsData', [
+            'defs'   => clusterlane_tip_definitions(),
+            'glyphs' => clusterlane_glyph_paths(),
+            'thumbs' => \Clusterlane\Core\Thumbs::enabled() ? 1 : 0,
+            'labels' => [
+                'good' => __( 'Good:', 'clusterlane' ),
+                'fix'  => __( 'Improve:', 'clusterlane' ),
+                /* translators: %s: term */
+                'what' => __( 'What is %s?', 'clusterlane' ),
+            ],
+        ] );
+
+        $settings = $this->plugin->get( 'settings' );
+
+        // Onboarding trigger — computed here so wp_localize_script can use it.
+        $show_onboarding = false;
+        $ob_categories   = [];
+        $is_dashboard    = strpos( $hook, 'clusterlane-dashboard' ) !== false || strpos( $hook, 'toplevel_page_clusterlane' ) !== false;
+        if ( $is_dashboard && $settings && \Clusterlane\Core\Capabilities::can_manage() && ! $settings->cluster_terms_configured() ) {
+            $scanner = $this->plugin->get( 'scanner' );
+            $summary = $scanner ? $scanner->get_summary() : [];
+            if ( ( (int) ( $summary['total_scanned'] ?? 0 ) ) === 0 ) {
+                $show_onboarding = true;
+                $terms = get_categories( [ 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC', 'number' => 200 ] );
+                if ( ! is_wp_error( $terms ) ) {
+                    foreach ( $terms as $t ) {
+                        if ( (int) ( $t->count ?? 0 ) >= 2 ) {
+                            $ob_categories[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES ), 'count' => (int) $t->count ];
+                        }
+                    }
+                }
+            }
+        }
+
+        wp_enqueue_style( 'clusterlane-admin', CLUSTERLANE_URL . self::admin_css(), [], CLUSTERLANE_VERSION );
+
+        // Sticky bar — loaded on every Clusterlane page.
+        wp_enqueue_script( 'clusterlane-sticky-bar', CLUSTERLANE_URL . 'admin/js/clusterlane-sticky-bar.js', [], CLUSTERLANE_VERSION, true );
+        wp_localize_script( 'clusterlane-sticky-bar', 'clusterlaneStickyBar', [
+            'restUrl'            => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+            'nonce'              => wp_create_nonce( 'wp_rest' ),
+            'articleInspectorUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ),
+            'clusterInspectorUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+            'batchSize'          => (int) ( $settings ? $settings->get( 'batch_size', 25 ) : 25 ),
+            'siteName'           => get_bloginfo( 'name' ),
+            'pages'              => \Clusterlane\Core\PageReader::status(), // published pages still to read (0.98.178)
+        ] );
+
+        // vis-network 9.1.2 (minified, bundled — WordPress.org doesn't allow
+        // loading scripts from other servers, so there is no CDN fallback).
+        // The file once shipped as 0 bytes after a zip round-trip, which left
+        // the graphs stuck on "Loading graph…" — so a broken copy raises a
+        // clear admin notice instead of failing silently.
+        $vis_path = CLUSTERLANE_DIR . 'admin/js/clusterlane-graph-lib.js';
+        wp_register_script( 'clusterlane-vis', CLUSTERLANE_URL . 'admin/js/clusterlane-graph-lib.js', [], '9.1.2', true );
+        if ( ! is_readable( $vis_path ) || (int) filesize( $vis_path ) < 100 * 1024 ) {
+            add_action( 'admin_notices', function () {
+                if ( ! current_user_can( 'manage_options' ) ) return;
+                echo '<div class="notice notice-error"><p><strong>' . esc_html( \Clusterlane\Core\Brand::name() ) . ':</strong> '
+                    . esc_html__( 'the bundled graph library (admin/js/clusterlane-graph-lib.js) is missing or incomplete, so graphs cannot be drawn. Upload the plugin again to restore it.', 'clusterlane' )
+                    . '</p></div>';
+            } );
+        }
+
+        // dashboard.js only on non-inspector pages; inspector has its own
+        // clusterlaneData and doesn't need the dashboard init code running.
+        if ( strpos( $hook, 'clusterlane-article-inspector' ) === false && strpos( $hook, 'clusterlane-cluster-inspector' ) === false ) {
+            wp_enqueue_script( 'clusterlane-admin', CLUSTERLANE_URL . 'admin/js/dashboard.js',
+                ['clusterlane-vis'],
+                CLUSTERLANE_VERSION,
+                true
+            );
+        } else {
+            // On the inspector page we still need vis-network — enqueue it directly.
+            wp_enqueue_script( 'clusterlane-vis' );
+        }
+
+        // Cluster quick view (0.98.200) — the eye button in Report's cluster table and the Cluster Inspector list.
+        if ( strpos( $hook, 'clusterlane-report' ) !== false || strpos( $hook, 'clusterlane-cluster-inspector' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-cluster-quick', CLUSTERLANE_URL . 'admin/js/clusterlane-cluster-quick.js', [ 'clusterlane-tips' ], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-cluster-quick', 'clusterlaneQuickData', [
+                'rest'         => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'        => wp_create_nonce( 'wp_rest' ),
+                'inspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+            ] );
+        }
+
+        // Report — Print button, "Show all clusters" and (Pro) the AI summary.
+        if ( strpos( $hook, 'clusterlane-report' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-report', CLUSTERLANE_URL . 'admin/js/report.js', [ 'clusterlane-edition' ], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-report', 'clusterlaneReport', [
+                'restUrl'     => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'       => wp_create_nonce( 'wp_rest' ),
+                'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ),
+                'canManage'   => (int) \Clusterlane\Core\Capabilities::can_manage(),
+                'labels'      => [
+                    'less'    => __( 'Show fewer clusters', 'clusterlane' ),
+                    'write'   => __( 'Write summary with AI', 'clusterlane' ),
+                    'rewrite' => __( 'Write again', 'clusterlane' ),
+                    'writing' => __( 'Writing…', 'clusterlane' ),
+                    'failed'  => __( 'The summary could not be written.', 'clusterlane' ),
+                    'toSettings' => __( 'Go to Settings →', 'clusterlane' ),
+                    /* translators: 1: model name, 2: date and time */
+                    'meta'    => __( 'Written by %1$s · %2$s', 'clusterlane' ),
+                    'stale'   => __( 'The figures have changed since — write it again for an up-to-date summary.', 'clusterlane' ),
+                ],
+            ] );
+        }
+
+        // Settings — tabs + save bar.
+        if ( strpos( $hook, 'clusterlane-settings' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-settings-page', CLUSTERLANE_URL . 'admin/js/settings-page.js', [], CLUSTERLANE_VERSION, true );
+        }
+
+        // Cluster Admin — save bar + custom cluster editor.
+        if ( strpos( $hook, 'clusterlane-cluster-settings' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-cluster-admin', CLUSTERLANE_URL . 'admin/js/cluster-admin.js', [ 'clusterlane-admin' ], CLUSTERLANE_VERSION, true );
+            wp_add_inline_script( 'clusterlane-cluster-admin', 'window.clusterlaneData = window.clusterlaneData || {}; clusterlaneData.adminUrl = ' . wp_json_encode( admin_url() ) . ';', 'before' );
+            wp_enqueue_script( 'clusterlane-cluster-review', CLUSTERLANE_URL . 'admin/js/cluster-review.js', [ 'clusterlane-edition' ], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-cluster-review', 'clusterlaneReview', [
+                'restUrl' => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'   => wp_create_nonce( 'wp_rest' ),
+                'ciUrl'   => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+            ] );
+        }
+
+        // Text Match Scanner — only load on TMS page.
+        if ( strpos( $hook, 'clusterlane-text-match' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-tms', CLUSTERLANE_URL . 'admin/js/text-match.js',
+                [], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-tms', 'clusterlaneData', [
+                'restUrl'    => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'      => wp_create_nonce( 'wp_rest' ),
+                'version'    => CLUSTERLANE_VERSION,
+                'settingsUrl'=> esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ),
+                'canManage'  => (int) \Clusterlane\Core\Capabilities::can_manage(),
+                'adminUrl'   => esc_url_raw( admin_url() ),
+                'skipped'    => (int) $GLOBALS['wpdb']->get_var( "SELECT COUNT(*) FROM {$GLOBALS['wpdb']->prefix}clusterlane_tms_skipped" ), // phpcs:ignore
+                'categories' => ( function() {
+                    global $wpdb;
+                    $rows = $wpdb->get_results(
+                        "SELECT t.term_id AS id, t.name, tt.count
+                         FROM {$wpdb->terms} t
+                         INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
+                         WHERE tt.taxonomy = 'category' AND tt.count > 0
+                         ORDER BY t.name ASC
+                         LIMIT 200"
+                    );
+                    return array_map( fn( $r ) => [
+                        'id'    => (int) $r->id,
+                        'name'  => html_entity_decode( $r->name, ENT_QUOTES, 'UTF-8' ),
+                        'count' => (int) $r->count,
+                    ], $rows );
+                } )(),
+            ] );
+        }
+
+        // Cluster Inspector — only load on its own page.
+        if ( strpos( $hook, 'clusterlane-cluster-inspector' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-vis' );
+            wp_enqueue_script( 'clusterlane-cluster-inspector',
+                CLUSTERLANE_URL . 'admin/js/cluster-inspector.js',
+                [ 'clusterlane-vis' ], CLUSTERLANE_VERSION, true );
+            wp_enqueue_script( 'clusterlane-cluster-split', CLUSTERLANE_URL . 'admin/js/cluster-split.js', [ 'clusterlane-cluster-inspector' ], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-cluster-inspector', 'clusterlaneData', [
+                'restUrl'             => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'               => wp_create_nonce( 'wp_rest' ),
+                'version'             => CLUSTERLANE_VERSION,
+                'inspectorUrl'        => esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ),
+                'titleTrunc'          => $settings ? $settings->title_truncation() : 'none',
+                'titleTruncCustom'    => $settings ? $settings->title_truncation_custom() : '',
+                'settingsUrl'         => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ),
+                'canManage'           => (int) \Clusterlane\Core\Capabilities::can_manage(),
+                'linkTemplates'       => array_map(
+                    fn( $t ) => [ 'id' => $t['id'], 'name' => $t['name'] ],
+                    ( $settings ? $settings->link_templates() : [] )
+                ),
+            ] );
+        }
+
+        // SEO Titles (0.98.154) — clusterlaneData comes from clusterlane-admin below (restUrl, nonce, titleTrunc …).
+        if ( strpos( $hook, 'clusterlane-seo-titles' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-seo-titles', CLUSTERLANE_URL . 'admin/js/seo-titles.js', [ 'clusterlane-admin' ], CLUSTERLANE_VERSION, true );
+        }
+
+        // Article Inspector — only load on its own page.
+        if ( strpos( $hook, 'clusterlane-article-inspector' ) !== false ) {
+            wp_enqueue_script( 'clusterlane-article-inspector',
+                CLUSTERLANE_URL . 'admin/js/article-inspector.js',
+                [ 'clusterlane-vis' ], CLUSTERLANE_VERSION, true );
+            wp_localize_script( 'clusterlane-article-inspector', 'clusterlaneData', [
+                'restUrl'             => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+                'nonce'               => wp_create_nonce( 'wp_rest' ),
+                'version'             => CLUSTERLANE_VERSION,
+                'titleTrunc'          => $settings ? $settings->title_truncation() : 'none',
+                'titleTruncCustom'    => $settings ? $settings->title_truncation_custom() : '',
+                'clusterInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+                'settingsUrl'         => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ),
+                'linkSuggestUrl'      => esc_url_raw( admin_url( 'admin.php?page=clusterlane-link-suggest' ) ),
+                'clusterAdminUrl'     => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-settings' ) ),
+                'canManage'           => (int) \Clusterlane\Core\Capabilities::can_manage(),
+            ] );
+        }
+
+        wp_localize_script( 'clusterlane-admin', 'clusterlaneData', [
+            'restUrl'             => esc_url_raw( rest_url( 'clusterlane/v1/' ) ),
+            'nonce'               => wp_create_nonce( 'wp_rest' ),
+            'version'             => CLUSTERLANE_VERSION,
+            'clusterInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+            'articleInspectorUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ),
+            'titleTrunc'          => $settings ? $settings->title_truncation() : 'none',
+            'titleTruncCustom'    => $settings ? $settings->title_truncation_custom() : '',
+            'onboarding'          => $show_onboarding,
+            'canManage'           => (int) \Clusterlane\Core\Capabilities::can_manage(),
+            'onboardingCategories'=> $ob_categories,
+            'categories'   => ( function() {
+                global $wpdb;
+                $rows = $wpdb->get_results(
+                    "SELECT t.term_id AS id, t.name, tt.count
+                     FROM {$wpdb->terms} t
+                     INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
+                     WHERE tt.taxonomy = 'category' AND tt.count > 0
+                     ORDER BY t.name ASC
+                     LIMIT 200"
+                );
+                return array_map( fn( $r ) => [
+                    'id'    => (int) $r->id,
+                    'name'  => html_entity_decode( $r->name, ENT_QUOTES, 'UTF-8' ),
+                    'count' => (int) $r->count,
+                ], $rows );
+            } )(),
+            'themeStyleUrl'=> esc_url_raw( get_stylesheet_uri() ),
+            'linkTemplates'=> array_map(
+                fn( $t ) => [ 'id' => $t['id'], 'name' => $t['name'] ],
+                ( $this->plugin->get('settings') ? $this->plugin->get('settings')->link_templates() : [] )
+            ),
+            'batchSize'   => (int) ( $this->plugin->get('settings') ? $this->plugin->get('settings')->get('batch_size', 25) : 25 ),
+            'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=clusterlane-settings' ) ),
+            'graphSettings' => array_merge( [
+                'avoid_overlap'   => 1.1,
+                'spring_length'   => 180,
+                'central_gravity' => 0.12,
+                'repulsion'       => 320,
+                'iterations'      => 220,
+                'graph_height'    => 420,
+            ], (array) ( $this->plugin->get('settings') ? $this->plugin->get('settings')->get('graph_display', []) : [] ) ),
+            'i18n'      => [
+                'scanning' => __( 'Scanning…', 'clusterlane' ),
+                'complete' => __( 'Scan complete', 'clusterlane' ),
+                'error'    => __( 'Error — check console', 'clusterlane' ),
+            ],
+        ] );
+
+        // Premium scripts (AI Link Suggestions, link modal, Link Card Templates, AI UI handlers).
+        do_action( 'clusterlane_enqueue_assets', $hook, $this->plugin );
+    }
+
+    /* ------------------------------------------------------------------
+       Shared sub-navigation bar (shown on every page except dashboard)
+    ------------------------------------------------------------------ */
+
+    private function render_subnav( string $active ): void {
+        clusterlane_render_sticky_bar( $active );
+    }
+
+    /* ------------------------------------------------------------------
+       Dashboard
+    ------------------------------------------------------------------ */
+
+    public function render_dashboard(): void {
+        $settings    = $this->plugin->get('settings');
+        $scanner     = $this->plugin->get('scanner');
+        $engine      = $this->plugin->get('cluster_engine');
+        $summary     = $scanner ? $scanner->get_summary() : [];
+        $csumm       = $engine  ? $engine->get_summary()  : [];
+        $clusters    = $engine  ? $engine->get_clusters()  : [];
+
+        $th          = $settings ? $settings->get( 'thresholds', [] ) : [];
+        $health_green  = (int) ( $th['health_green']  ?? 70 );
+        $health_orange = (int) ( $th['health_orange'] ?? 50 );
+
+        $total    = $summary['total_posts']   ?? 0;
+        $scanned  = $summary['total_scanned'] ?? 0;
+        $score    = $summary['avg_score']     ?? 0;
+        $inbound  = $summary['avg_inbound']   ?? 0;
+        $orphans  = $summary['orphan_count']  ?? 0;
+        $last     = $summary['last_scan']     ?? null;
+
+        $total_clusters = $csumm['total_clusters'] ?? 0;
+        $avg_health     = $csumm['avg_health']     ?? 0;
+
+        // Archetype distribution + a member-weighted health average for
+        // the two new overview panels below (a 43-article cluster should
+        // count for more than a 5-article one, unlike the simple
+        // per-cluster average already shown in the KPI cards above).
+        $arch_colors = [
+            'Authority Pyramid'    => '#3b82f6',
+            'Balanced Mesh'        => '#10b981',
+            'Developed Cluster'    => '#0ea5e9',
+            'Over-Centralized Hub' => '#ef4444',
+            'Fragmented'           => '#f59e0b',
+            'Weak Structure'       => '#8b5cf6',
+            'Emerging Cluster'     => '#64748b',
+            'Mixed'                => '#94a3b8',
+            'Unclassified'         => '#cbd5e1', // never computed
+        ];
+        $arch_counts = array_fill_keys( array_keys( $arch_colors ), 0 );
+        foreach ( $clusters as $c ) {
+            $arch = $c->archetype ?: 'Unclassified';
+            if ( isset( $arch_counts[ $arch ] ) ) $arch_counts[ $arch ]++;
+        }
+        $avg_cluster_health = \Clusterlane\Core\ClusterEngine::weighted_health( $clusters );
+
+        $ring_color    = $avg_cluster_health >= 70 ? '#10b981' : ( $avg_cluster_health >= 50 ? '#f59e0b' : '#ef4444' );
+
+        // Big ring for the hero (separate radius from the old standalone
+        // panel version, which has been folded into the hero below).
+        $hero_r             = 54;
+        $hero_circumference = 2 * M_PI * $hero_r;
+        $hero_dash_offset   = $hero_circumference - ( $avg_cluster_health / 100 ) * $hero_circumference;
+
+        // Stacked-bar segments for the archetype mix — proportional to
+        // the TOTAL cluster count (so segments sum to 100%), unlike the
+        // old per-archetype mini bars which were each scaled to the
+        // largest single archetype instead.
+        $arch_total = array_sum( $arch_counts ) ?: 1;
+
+        $ci_url  = admin_url( 'admin.php?page=clusterlane-cluster-inspector' );
+        $ai_url  = admin_url( 'admin.php?page=clusterlane-article-inspector' );
+
+        // Focus next on the Dashboard too (0.98.185) — the same pick as in Cluster
+        // Inspector: clusters under 70% with 2+ articles, low health weighted by
+        // size, plus 4 points per orphan. Dashboard is the start page, so the
+        // next step belongs here as well.
+        $focus = [];
+        if ( $clusters ) {
+            global $wpdb;
+            $orph = [];
+            foreach ( (array) $wpdb->get_results( "SELECT cluster_key, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE content_class = 'orphan' GROUP BY cluster_key" ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+                $orph[ $r->cluster_key ] = (int) $r->n;
+            }
+            foreach ( $clusters as $c ) {
+                $h = (int) $c->health_score; $m = (int) $c->total_members; $o = $orph[ $c->cluster_key ] ?? 0;
+                if ( $h >= $health_green || $m < 2 || ! (int) ( $c->is_active ?? 1 ) ) continue;
+                $focus[] = [ 'key' => $c->cluster_key, 'label' => $c->cluster_label, 'h' => $h, 'm' => $m, 'o' => $o, 'score' => ( $health_green - $h ) * log( $m + 1, 2 ) + $o * 4 ];
+            }
+            usort( $focus, fn( $a, $b ) => $b['score'] <=> $a['score'] ?: $a['h'] <=> $b['h'] );
+            $focus = array_slice( $focus, 0, 3 );
+        }
+        $tms_url = admin_url( 'admin.php?page=clusterlane-text-match' );
+
+        // Show first-run onboarding if never configured and nothing scanned yet.
+        // Onboarding saves cluster config → admins (manage_clusterlane) only.
+        $show_onboarding = \Clusterlane\Core\Capabilities::can_manage() && ! $settings->cluster_terms_configured() && $scanned === 0;
+        $ob = $show_onboarding ? $this->onboarding_data( $settings ) : [ 'types' => [], 'groups' => [], 'hubs' => [] ];
+
+        // 0.98.177: scanned post types with almost no links in their text whose
+        // published pages are not read — the theme probably links them.
+        $low_link = [];
+        if ( $scanned > 0 && \Clusterlane\Core\Capabilities::can_manage() ) {
+            foreach ( array_diff( \Clusterlane\Core\PageReader::recommended( $settings->post_types() ), $settings->rendered_types() ) as $pt ) {
+                $obj = get_post_type_object( $pt );
+                $low_link[] = $obj ? $obj->labels->name : $pt;
+            }
+        }
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php clusterlane_render_sticky_bar( 'dashboard' ); ?>
+            <?php if ( $low_link ): ?>
+            <div class="clusterlane-low-links" role="note">
+                <?php clusterlane_glyph_e( 'link' ); ?>
+                <p><strong><?php
+                    /* translators: %s: post type names, e.g. "Attractions, Areas" */
+                    echo esc_html( sprintf( __( '%s have almost no links in their text.', 'clusterlane' ), implode( ', ', $low_link ) ) );
+                ?></strong> <?php esc_html_e( 'If your theme links them through lists, cards or related sections, Clusterlane only sees those links on the published page — until then they may show up as orphans.', 'clusterlane' ); ?>
+                <a href="<?php echo esc_url( admin_url( 'admin.php?page=clusterlane-settings&tab=content#clusterlane-rendered-row' ) ); ?>"><?php esc_html_e( 'Read the published pages →', 'clusterlane' ); ?></a></p>
+            </div>
+            <?php endif; ?>
+
+            <div class="clusterlane-hero">
+                <div class="clusterlane-hero-top">
+                    <div class="clusterlane-hero-title">
+                        <span class="clusterlane-hero-mark"><?php \Clusterlane\Core\Brand::mark_e( 40, '#fff', \Clusterlane\Core\Brand::INK ); ?></span>
+                        <div>
+                            <h1><?php echo esc_html( \Clusterlane\Core\Brand::name() ); ?></h1>
+                            <span class="clusterlane-hero-version">v<?php echo esc_html( CLUSTERLANE_VERSION ); ?></span>
+                        </div>
+                    </div>
+                    <?php if ( $last ): ?>
+                    <div class="clusterlane-hero-lastscan">
+                        <?php $last_ts = strtotime( $last . ' +0000' ); printf( /* translators: %s: text */ esc_html__( 'Last scan: %s', 'clusterlane' ), esc_html( human_time_diff( $last_ts, time() ) . ' ago' ) ); ?>
+                    </div>
+                    <?php endif; ?>
+                </div>
+
+                <div class="clusterlane-hero-body">
+                    <div class="clusterlane-hero-stats">
+                        <?php // One figure when everything is scanned (0.98.187; "91 Total posts · 91 Scanned" said the same twice). ?>
+                        <div class="clusterlane-hero-stat">
+                            <span class="clusterlane-hero-stat-value"><?php echo esc_html( $scanned >= $total ? (string) $total : $scanned . ' / ' . $total ); ?></span>
+                            <span class="clusterlane-hero-stat-label"><?php $scanned >= $total ? esc_html_e( 'Articles', 'clusterlane' ) : esc_html_e( 'Scanned', 'clusterlane' ); ?></span>
+                        </div>
+                        <div class="clusterlane-hero-stat">
+                            <span class="clusterlane-hero-stat-value clusterlane-hero-stat-value--<?php echo $score >= 80 ? 'green' : ( $score >= 60 ? 'amber' : 'red' ); ?>"><?php echo esc_html( $score ); ?></span>
+                            <span class="clusterlane-hero-stat-label"><?php esc_html_e( 'Avg score', 'clusterlane' ); ?><?php clusterlane_tip( 'avg_score' ); ?></span>
+                        </div>
+                        <div class="clusterlane-hero-stat">
+                            <span class="clusterlane-hero-stat-value"><?php echo esc_html( $inbound ); ?></span>
+                            <span class="clusterlane-hero-stat-label"><?php esc_html_e( 'Avg inbound', 'clusterlane' ); ?><?php clusterlane_tip( 'avg_inbound' ); ?></span>
+                        </div>
+                        <div class="clusterlane-hero-stat">
+                            <span class="clusterlane-hero-stat-value clusterlane-hero-stat-value--<?php echo $orphans > 0 ? 'red' : 'green'; ?>"><?php echo esc_html( $orphans ); ?></span>
+                            <span class="clusterlane-hero-stat-label"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?><?php clusterlane_tip( 'orphans' ); ?></span>
+                        </div>
+                        <div class="clusterlane-hero-stat">
+                            <span class="clusterlane-hero-stat-value"><?php echo esc_html( $total_clusters ); ?></span>
+                            <span class="clusterlane-hero-stat-label"><?php esc_html_e( 'Clusters', 'clusterlane' ); ?></span>
+                        </div>
+                    </div>
+
+                    <div class="clusterlane-hero-health">
+                        <div class="clusterlane-hero-ring">
+                            <svg width="132" height="132" viewBox="0 0 132 132">
+                                <circle class="clusterlane-hero-ring-bg" cx="66" cy="66" r="<?php echo (int) $hero_r; ?>"/>
+                                <circle class="clusterlane-hero-ring-fill"
+                                    cx="66" cy="66" r="<?php echo (int) $hero_r; ?>"
+                                    stroke="<?php echo esc_attr( $ring_color ); ?>"
+                                    stroke-dasharray="<?php echo esc_attr( (string) round( $hero_circumference, 2 ) ); ?>"
+                                    stroke-dashoffset="<?php echo esc_attr( (string) round( $hero_dash_offset, 2 ) ); ?>"/>
+                            </svg>
+                            <div class="clusterlane-hero-ring-label">
+                                <span class="clusterlane-hero-ring-value" style="color:<?php echo esc_attr( $ring_color ); ?>"><?php echo (int) $avg_cluster_health; ?>%</span>
+                                <span class="clusterlane-hero-ring-sub"><?php esc_html_e( 'health', 'clusterlane' ); ?></span>
+                            </div>
+                        </div>
+                        <div class="clusterlane-hero-health-desc">
+                            <?php clusterlane_tip( 'health' ); ?>
+                            <?php
+                            if ( $avg_cluster_health >= 70 )      esc_html_e( 'Clusters are well-structured.', 'clusterlane' );
+                            elseif ( $avg_cluster_health >= 50 )  esc_html_e( 'Some clusters need attention — see Focus next below.', 'clusterlane' );
+                            else                                  esc_html_e( 'Significant structural gaps.', 'clusterlane' );
+                            ?>
+                        </div>
+                    </div>
+
+                    <div class="clusterlane-hero-archetypes">
+                        <div class="clusterlane-hero-archetypes-label"><?php esc_html_e( 'Archetype mix', 'clusterlane' ); ?><?php clusterlane_tip( 'archetype_mix' ); ?></div>
+                        <div class="clusterlane-arch-stacked-bar">
+                            <?php foreach ( $arch_counts as $arch => $count ):
+                                if ( $count === 0 ) continue;
+                                $color = $arch_colors[ $arch ] ?? '#777';
+                                $pct   = round( ( $count / $arch_total ) * 100, 2 );
+                            ?>
+                            <div class="clusterlane-arch-seg" style="width:<?php echo esc_attr( (string) $pct ); ?>%;background:<?php echo esc_attr( $color ); ?>"
+                                 title="<?php echo esc_attr( $arch . ': ' . $count ); ?>"></div>
+                            <?php endforeach; ?>
+                        </div>
+                        <div class="clusterlane-arch-stacked-legend">
+                            <?php foreach ( $arch_counts as $arch => $count ):
+                                if ( $count === 0 ) continue;
+                                $color = $arch_colors[ $arch ] ?? '#777';
+                            ?>
+                            <span class="clusterlane-arch-legend-item">
+                                <span class="clusterlane-arch-dot" style="background:<?php echo esc_attr( $color ); ?>"></span>
+                                <?php echo esc_html( $arch ); ?> <strong><?php echo (int) $count; ?></strong>
+                            </span>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                </div>
+
+            <?php if ( empty( $summary['total_scanned'] ) ): ?>
+            <p class="clusterlane-hero-firstscan"><?php clusterlane_glyph_e( 'arrow' ); ?><?php esc_html_e( 'No articles scanned yet — click Scan in the bar above to start. It lists every article, measures it and builds your clusters.', 'clusterlane' ); ?></p>
+            <?php endif; ?>
+
+            </div><!-- /clusterlane-hero -->
+
+            <?php if ( $clusters && $scanned > 0 ): ?>
+            <!-- Focus next (0.98.185): the same three clusters as in Cluster Inspector; when none
+                 is under the green line, say so (0.98.186) instead of leaving the card out. -->
+            <section class="clusterlane-panel-card clusterlane-dash-focus" aria-labelledby="clusterlane-dash-focus-h">
+                <div class="clusterlane-dash-focus__head">
+                    <h2 id="clusterlane-dash-focus-h"><?php clusterlane_glyph_e( 'target' ); ?><?php esc_html_e( 'Focus next', 'clusterlane' ); ?><?php clusterlane_tip( 'focus_next' ); ?></h2>
+                    <span class="clusterlane-tm-note"><?php esc_html_e( 'Clusters where a few links make the biggest difference', 'clusterlane' ); ?></span>
+                </div>
+                <?php if ( ! $focus ): ?>
+                <p class="clusterlane-ci-focus-done"><?php clusterlane_glyph_e( 'check' ); ?><?php
+                    /* translators: %d: health threshold, e.g. 70 */
+                    echo esc_html( sprintf( __( 'All clusters are at %d%% health or more — nothing urgent. Cluster Inspector has the full list.', 'clusterlane' ), $health_green ) );
+                ?></p>
+                <?php endif; ?>
+                <div class="clusterlane-ci-focus-grid">
+                    <?php foreach ( $focus as $i => $f ):
+                        $band = $f['h'] >= $health_green ? 'good' : ( $f['h'] >= $health_orange ? 'fair' : 'weak' );
+                        /* translators: %d: number of articles */
+                        $why = $f['o'] ? sprintf( _n( '%d article has no links pointing to it', '%d articles have no links pointing to them', $f['o'], 'clusterlane' ), $f['o'] ) : __( 'Its articles link to each other too little', 'clusterlane' );
+                    ?>
+                    <a class="clusterlane-ci-focus-card clusterlane-ci-detail-health--<?php echo esc_attr( $band ); ?>" href="<?php echo esc_url( add_query_arg( 'open', rawurlencode( $f['key'] ), $ci_url ) ); ?>">
+                        <span class="clusterlane-ci-focus-top">
+                            <span class="clusterlane-ci-focus-rank"><?php echo (int) ( $i + 1 ); ?></span>
+                            <span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--<?php echo esc_attr( $band ); ?>"><span class="clusterlane-ci-health-node" aria-hidden="true"></span><?php echo (int) $f['h']; ?>%</span>
+                            <span class="clusterlane-ci-focus-size"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d article', '%d articles', $f['m'], 'clusterlane' ), $f['m'] ) ); ?></span>
+                        </span>
+                        <strong class="clusterlane-ci-focus-name"><?php echo esc_html( $f['label'] ); ?></strong>
+                        <span class="clusterlane-ci-focus-why"><?php echo esc_html( $why ); ?></span>
+                        <span class="clusterlane-ci-focus-go"><?php esc_html_e( 'Improve', 'clusterlane' ); ?> <?php clusterlane_glyph_e( 'arrow' ); ?></span>
+                    </a>
+                    <?php endforeach; ?>
+                </div>
+            </section>
+            <?php endif; ?>
+
+            <!-- Site map: every cluster at a glance (treemap; area = articles, colour = health or orphan share) -->
+            <details class="clusterlane-panel-card clusterlane-dash-graph-details clusterlane-sm-card" id="clusterlane-sm-card" open>
+                <summary class="clusterlane-site-analysis-header">
+                    <div class="clusterlane-site-analysis-title">
+                        <span class="clusterlane-site-analysis-icon"><?php clusterlane_glyph_e( 'layers' ); ?></span>
+                        <div>
+                            <strong><?php esc_html_e( 'Site map', 'clusterlane' ); ?></strong><?php clusterlane_tip( 'site_map' ); ?>
+                            <span class="clusterlane-site-analysis-sub"><?php esc_html_e( 'Every cluster at a glance. Click one to show it in the graph below.', 'clusterlane' ); ?></span>
+                        </div>
+                    </div>
+                    <div class="clusterlane-dash-graph-controls">
+                        <span class="clusterlane-toolbar-label"><?php esc_html_e( 'Colour', 'clusterlane' ); ?></span>
+                        <div class="clusterlane-seg" id="clusterlane-sm-mode" role="group" aria-label="<?php esc_attr_e( 'Colour by', 'clusterlane' ); ?>">
+                            <button type="button" class="clusterlane-seg-btn is-active" data-mode="health" aria-pressed="true"><?php esc_html_e( 'Health', 'clusterlane' ); ?></button>
+                            <button type="button" class="clusterlane-seg-btn" data-mode="orphans" aria-pressed="false"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?></button>
+                        </div>
+                    </div>
+                </summary>
+                <div class="clusterlane-sm-body">
+                    <div class="clusterlane-sm-canvas" id="clusterlane-sm-canvas"><p class="clusterlane-sm-empty"><?php esc_html_e( 'Loading…', 'clusterlane' ); ?></p></div>
+                    <div class="clusterlane-sm-foot">
+                        <div class="clusterlane-sm-legend" id="clusterlane-sm-legend"></div>
+                        <p class="clusterlane-sm-takeaway" id="clusterlane-sm-takeaway"></p>
+                    </div>
+                </div>
+            </details>
+
+            <!-- Cluster Graph -->
+            <details class="clusterlane-panel-card clusterlane-dash-graph-details" id="clusterlane-dash-graph-card" open>
+                <summary class="clusterlane-site-analysis-header">
+                    <div class="clusterlane-site-analysis-title">
+                        <span class="clusterlane-site-analysis-icon"><?php clusterlane_glyph_e( 'nodes' ); ?></span>
+                        <div>
+                            <strong><?php esc_html_e( 'Cluster Graph', 'clusterlane' ); ?><?php clusterlane_tip( 'graph_layout' ); ?></strong>
+                        </div>
+                    </div>
+                    <div class="clusterlane-dash-graph-controls">
+                        <label for="clusterlane-dash-graph-select"><?php esc_html_e( 'Cluster:', 'clusterlane' ); ?></label>
+                        <select id="clusterlane-dash-graph-select">
+                            <option value=""><?php esc_attr_e( 'Loading clusters…', 'clusterlane' ); ?></option>
+                        </select>
+                        <a id="clusterlane-dash-graph-ci-link" href="<?php echo esc_url( $ci_url ); ?>" class="clusterlane-action-btn clusterlane-dash-graph-ci-btn" style="display:none;text-decoration:none;">
+                            <?php esc_html_e( 'Open in Inspector →', 'clusterlane' ); ?>
+                        </a>
+                    </div>
+                </summary>
+                <div id="clusterlane-dash-graph-notice" style="display:none;"></div>
+                <div id="clusterlane-dash-graph-container"></div>
+                <div id="clusterlane-dash-graph-info" style="display:none;"></div>
+            </details>
+
+
+            <details class="clusterlane-site-analysis-card" id="clusterlane-site-analysis-card">
+                <summary class="clusterlane-site-analysis-header">
+                    <div class="clusterlane-site-analysis-title">
+                        <span class="clusterlane-site-analysis-icon"><?php clusterlane_glyph_e( 'sparkle' ); ?></span>
+                        <div>
+                            <strong><?php esc_html_e( 'Site Intelligence Analysis', 'clusterlane' ); ?></strong>
+                            <span class="clusterlane-site-analysis-sub"><?php esc_html_e( 'AI-powered overview of your content structure — strengths, gaps and opportunities across all clusters. Read-only: nothing is changed without your approval.', 'clusterlane' ); ?></span>
+                        </div>
+                    </div>
+                    <div class="clusterlane-site-analysis-actions">
+                        <span id="clusterlane-site-analysis-meta" class="clusterlane-site-analysis-meta"></span>
+                        <div class="clusterlane-sa-btn-wrap">
+                            <button id="clusterlane-site-analysis-btn" class="clusterlane-action-btn clusterlane-action-btn--secondary" style="min-width:0;">
+                                <?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Analyse site', 'clusterlane' ); ?>
+                            </button>
+                            <span class="clusterlane-sa-ai-badge"><?php esc_html_e( 'AI · uses ~2 k tokens', 'clusterlane' ); ?></span>
+                        </div>
+                        <button id="clusterlane-site-analysis-regen" class="clusterlane-action-btn clusterlane-action-btn--secondary" style="min-width:0;display:none;" title="<?php esc_attr_e( 'Write the analysis again — a new AI call', 'clusterlane' ); ?>">
+                            <?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Regenerate', 'clusterlane' ); ?>
+                        </button>
+                        <button id="clusterlane-sa-print" class="clusterlane-action-btn clusterlane-action-btn--tertiary" style="min-width:0;display:none;" title="Save as PDF / Print" onclick="window.print()">
+                            <?php clusterlane_glyph_e( 'printer' ); ?>
+                        </button>
+                    </div>
+                </summary>
+                <div id="clusterlane-site-analysis-body">
+                    <div id="clusterlane-site-analysis-result"></div>
+                </div>
+            </details>
+
+            <?php if ( ! empty( $clusters ) ): ?>
+            <!-- Edit cluster modal -->
+                <div id="clusterlane-edit-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100000;align-items:center;justify-content:center;">
+                    <div id="clusterlane-modal-inner" style="background:#fff;border-radius:8px;padding:28px;width:1100px;max-width:96vw;min-height:75vh;max-height:92vh;overflow-y:auto;position:relative;">
+                        <button id="clusterlane-modal-close" style="position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.4rem;cursor:pointer;color:#888;">×</button>
+                        <div id="clusterlane-modal-header" class="clusterlane-modal-header">
+                            <h2 id="clusterlane-modal-title" class="clusterlane-modal-title"></h2>
+                            <div id="clusterlane-modal-stats" class="clusterlane-modal-stats"></div>
+                        </div>
+
+                        <!-- Members panel -->
+                        <div id="clusterlane-modal-members-panel">
+                            <div id="clusterlane-modal-rename" style="margin-bottom:16px;display:none;">
+                                <label style="display:block;font-weight:600;margin-bottom:4px;"><?php esc_html_e( 'Cluster name', 'clusterlane' ); ?></label>
+                                <input type="text" id="clusterlane-modal-label-input" class="regular-text">
+                                <button id="clusterlane-modal-rename-btn" class="button" style="margin-left:8px;"><?php esc_html_e( 'Rename', 'clusterlane' ); ?></button>
+                            </div>
+
+                            <div style="margin-bottom:16px;">
+                                <label style="display:block;font-weight:600;margin-bottom:4px;"><?php esc_html_e( 'Add articles', 'clusterlane' ); ?></label>
+                                <div class="clusterlane-search-wrap" style="max-width:100%;">
+                                    <svg class="clusterlane-search-icon" width="15" height="15" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+                                        <circle cx="9" cy="9" r="6.5" stroke="currentColor" stroke-width="1.8"/>
+                                        <path d="M18 18L14 14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+                                    </svg>
+                                    <input type="text" id="clusterlane-modal-search" class="clusterlane-search-input" placeholder="<?php esc_attr_e( 'Search by title…', 'clusterlane' ); ?>">
+                                </div>
+                                <div id="clusterlane-modal-search-results" style="display:none;border:1px solid #ddd;border-radius:8px;margin-top:6px;max-height:180px;overflow-y:auto;background:#fff;position:relative;z-index:1;"></div>
+                            </div>
+
+                            <div>
+                                <label style="display:block;font-weight:600;margin-bottom:8px;"><?php esc_html_e( 'Current members', 'clusterlane' ); ?></label>
+                                <div id="clusterlane-modal-members" style="max-height:250px;overflow-y:auto;border:1px solid #eee;border-radius:4px;"></div>
+                            </div>
+                        </div>
+
+                        <!-- Graph panel (injected by JS) -->
+                        <div id="clusterlane-modal-graph-panel" style="display:none;">
+                            <div id="clusterlane-graph-canvas-wrap" style="position:relative;">
+                                <div id="clusterlane-vis-graph" style="width:100%;height:420px;border:1px solid #eee;border-radius:4px;background:#fafafa;"></div>
+                                <button id="clusterlane-expand-btn" class="clusterlane-graph-expand-btn" style="display:none;"><?php clusterlane_glyph_e( 'search' ); ?>Expand</button>
+                                <div id="clusterlane-graph-overlay" class="clusterlane-graph-overlay">
+                                    <div id="clusterlane-graph-status" class="clusterlane-graph-status"></div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Gap Analysis panel (content injected by JS) -->
+                        <div id="clusterlane-modal-gap-panel" style="display:none;">
+                            <div id="clusterlane-gap-content"></div>
+                        </div>
+
+                        <div id="clusterlane-modal-status" style="margin-top:12px;font-size:.85rem;min-height:20px;"></div>
+                    </div>
+                </div>
+
+            <?php else: ?>
+            <div class="clusterlane-scan-section" style="margin-top:24px;">
+                <p><?php esc_html_e( 'No clusters yet. Pick your clusters in Cluster Admin, then click Scan in the bar at the top.', 'clusterlane' ); ?></p>
+            </div>
+            <?php endif; ?>
+
+
+
+        </div><!-- /.clusterlane-wrap -->
+
+        <?php if ( $show_onboarding ): ?>
+        <!-- First-run onboarding modal -->
+        <div id="clusterlane-onboarding-overlay" class="clusterlane-onboarding-overlay">
+            <div class="clusterlane-onboarding-modal">
+
+                <!-- Step 1: Welcome -->
+                <div class="clusterlane-ob-step" id="clusterlane-ob-step-1">
+                    <div class="clusterlane-ob-icon" style="display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;background:linear-gradient(135deg,#3b82f6,#1e40af);border-radius:14px;margin:0 auto 16px;">
+                        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
+                    </div>
+                    <h2 class="clusterlane-ob-title"><?php esc_html_e( 'Welcome to Clusterlane', 'clusterlane' ); ?></h2>
+                    <p class="clusterlane-ob-desc"><?php esc_html_e( 'Clusterlane helps you build a stronger internal link structure by grouping your articles into content clusters — topic hubs that search engines (and readers) love.', 'clusterlane' ); ?></p>
+                    <p class="clusterlane-ob-desc"><?php esc_html_e( "Pick what to analyse, run the first scan, and your content map is ready — usually in a minute or two.", 'clusterlane' ); ?></p>
+                    <div class="clusterlane-ob-actions">
+                        <button class="clusterlane-action-btn clusterlane-action-btn--primary clusterlane-ob-next" data-next="2">
+                            <?php esc_html_e( "Let's go →", 'clusterlane' ); ?>
+                        </button>
+                    </div>
+                </div>
+
+                <!-- Step 2: What to analyse (0.98.177: content types, published pages, clusters from any taxonomy) -->
+                <div class="clusterlane-ob-step" id="clusterlane-ob-step-2" style="display:none;">
+                    <h2 class="clusterlane-ob-title"><?php clusterlane_glyph_e( 'folder' ); ?><?php esc_html_e( 'What should Clusterlane analyse?', 'clusterlane' ); ?></h2>
+                    <div class="clusterlane-ob-scroll">
+                        <h3 class="clusterlane-ob-sub"><?php esc_html_e( 'Content', 'clusterlane' ); ?></h3>
+                        <p class="clusterlane-ob-hint"><?php esc_html_e( 'The kinds of pages to scan.', 'clusterlane' ); ?></p>
+                        <div class="clusterlane-ob-category-list clusterlane-ob-types">
+                            <?php foreach ( $ob['types'] as $t ): ?>
+                            <label class="clusterlane-ob-category-item">
+                                <input type="checkbox" class="clusterlane-ob-type" value="<?php echo esc_attr( $t['name'] ); ?>" data-label="<?php echo esc_attr( $t['label'] ); ?>" data-rec="<?php echo $t['recommended'] ? '1' : '0'; ?>" <?php checked( $t['checked'] ); ?>>
+                                <span class="clusterlane-ob-cat-name"><?php echo esc_html( $t['label'] ); ?></span>
+                                <span class="clusterlane-ob-cat-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d page', '%d pages', $t['count'], 'clusterlane' ), $t['count'] ) ); ?></span>
+                            </label>
+                            <?php endforeach; ?>
+                        </div>
+                        <div class="clusterlane-ob-rendered" id="clusterlane-ob-rendered" hidden>
+                            <label>
+                                <input type="checkbox" id="clusterlane-ob-rendered-check" checked>
+                                <span><?php esc_html_e( 'Also read the published pages of', 'clusterlane' ); ?> <strong id="clusterlane-ob-rendered-names"></strong> <?php esc_html_e( '(recommended)', 'clusterlane' ); ?></span>
+                            </label>
+                            <p class="clusterlane-ob-hint"><?php esc_html_e( 'These pages have almost no links in their text, so your theme probably links them through lists or cards. Clusterlane can only see those links on the published page. The first scan takes a little longer — about a second per page. You can change this later under Settings → Content.', 'clusterlane' ); ?></p>
+                        </div>
+
+                        <h3 class="clusterlane-ob-sub"><?php esc_html_e( 'Clusters', 'clusterlane' ); ?></h3>
+                        <p class="clusterlane-ob-hint"><?php esc_html_e( 'Topic groups Clusterlane tracks — pick your main ones. Tags, custom clusters and AI ideas can be added any time in Cluster Admin.', 'clusterlane' ); ?></p>
+                        <?php if ( $ob['hubs'] ): ?>
+                        <div class="clusterlane-ob-group clusterlane-ob-hubs" data-types="<?php echo esc_attr( implode( ' ', array_unique( array_merge( ...array_map( fn( $h ) => $h['types'], $ob['hubs'] ) ) ) ) ); ?>">
+                            <div class="clusterlane-ob-group-head"><span><?php esc_html_e( 'Pages as clusters', 'clusterlane' ); ?></span></div>
+                            <div class="clusterlane-ob-category-list">
+                                <?php foreach ( $ob['hubs'] as $h ): ?>
+                                <label class="clusterlane-ob-category-item clusterlane-ob-hub" data-types="<?php echo esc_attr( implode( ' ', $h['types'] ) ); ?>">
+                                    <input type="checkbox" class="clusterlane-ob-hub-check" value="<?php echo esc_attr( $h['type'] ); ?>" <?php checked( $h['checked'] ); ?>>
+                                    <span class="clusterlane-ob-cat-name"><?php
+                                        /* translators: 1: post type, e.g. "Islands", 2: post types, e.g. "areas, attractions" */
+                                        echo esc_html( sprintf( __( '%1$s — each page a cluster, with its %2$s', 'clusterlane' ), $h['label'], mb_strtolower( implode( ', ', $h['with'] ) ) ) );
+                                    ?></span>
+                                    <span class="clusterlane-ob-cat-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d page', '%d pages', $h['pages'], 'clusterlane' ), $h['pages'] ) ); ?></span>
+                                </label>
+                                <?php endforeach; ?>
+                            </div>
+                        </div>
+                        <?php endif; ?>
+                        <?php foreach ( $ob['groups'] as $g ): ?>
+                        <div class="clusterlane-ob-group" data-types="<?php echo esc_attr( implode( ' ', $g['types'] ) ); ?>">
+                            <div class="clusterlane-ob-group-head">
+                                <span><?php echo esc_html( $g['label'] ); ?></span>
+                                <button type="button" class="clusterlane-ob-group-all"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></button>
+                            </div>
+                            <div class="clusterlane-ob-category-list">
+                                <?php foreach ( $g['terms'] as $term ): ?>
+                                <label class="clusterlane-ob-category-item">
+                                    <input type="checkbox" class="clusterlane-ob-cat-check" data-tax="<?php echo esc_attr( $g['taxonomy'] ); ?>" value="<?php echo esc_attr( (string) $term['id'] ); ?>" <?php checked( ! empty( $term['checked'] ) ); ?>>
+                                    <span class="clusterlane-ob-cat-name"><?php echo esc_html( $term['name'] ); ?></span>
+                                    <span class="clusterlane-ob-cat-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d page', '%d pages', $term['count'], 'clusterlane' ), $term['count'] ) ); ?></span>
+                                </label>
+                                <?php endforeach; ?>
+                            </div>
+                        </div>
+                        <?php endforeach; ?>
+                        <p class="clusterlane-ob-hint clusterlane-ob-no-groups" id="clusterlane-ob-no-groups" hidden><?php esc_html_e( 'No categories with two or more pages for this content yet. Continue without — you can add clusters in Cluster Admin, or let AI suggest some.', 'clusterlane' ); ?></p>
+                    </div>
+                    <div id="clusterlane-ob-step2-status" class="clusterlane-ob-status"></div>
+                    <div class="clusterlane-ob-actions">
+                        <button class="clusterlane-action-btn clusterlane-action-btn--secondary clusterlane-ob-back" data-back="1" style="min-width:0;">← <?php esc_html_e( 'Back', 'clusterlane' ); ?></button>
+                        <button class="clusterlane-action-btn clusterlane-action-btn--primary" id="clusterlane-ob-save-clusters">
+                            <?php esc_html_e( 'Save & continue →', 'clusterlane' ); ?>
+                        </button>
+                    </div>
+                </div>
+
+                <!-- Step 3: Scan -->
+                <div class="clusterlane-ob-step" id="clusterlane-ob-step-3" style="display:none;">
+                    <h2 class="clusterlane-ob-title"><?php clusterlane_glyph_e( 'search' ); ?><?php esc_html_e( 'Scan your site', 'clusterlane' ); ?></h2>
+                    <p class="clusterlane-ob-desc"><?php esc_html_e( 'Clusterlane will now read through all your published articles to map their internal links and measure their content quality.', 'clusterlane' ); ?></p>
+                    <div class="clusterlane-ob-progress-wrap" id="clusterlane-ob-progress-wrap" style="display:none;">
+                        <div class="clusterlane-progress-bar-wrap">
+                            <div class="clusterlane-progress-bar" id="clusterlane-ob-progress-bar" style="width:0%;"></div>
+                        </div>
+                        <div class="clusterlane-ob-progress-label" id="clusterlane-ob-progress-label"></div>
+                    </div>
+                    <div id="clusterlane-ob-step3-status" class="clusterlane-ob-status"></div>
+                    <div class="clusterlane-ob-actions">
+                        <button class="clusterlane-action-btn clusterlane-action-btn--primary" id="clusterlane-ob-start-scan">
+                            <?php esc_html_e( 'Start scan', 'clusterlane' ); ?>
+                        </button>
+                    </div>
+                </div>
+
+                <!-- Step 4: Compute -->
+                <div class="clusterlane-ob-step" id="clusterlane-ob-step-4" style="display:none;">
+                    <h2 class="clusterlane-ob-title"><?php clusterlane_glyph_e( 'bolt' ); ?><?php esc_html_e( 'Computing cluster health', 'clusterlane' ); ?></h2>
+                    <p class="clusterlane-ob-desc"><?php esc_html_e( "Almost done — Clusterlane is working out each cluster's health and the role of every article.", 'clusterlane' ); ?></p>
+                    <div class="clusterlane-ob-spinner-wrap">
+                        <span class="clusterlane-spinner"></span>
+                    </div>
+                    <div id="clusterlane-ob-step4-status" class="clusterlane-ob-status"></div>
+                </div>
+
+                <!-- Step 5: Done -->
+                <div class="clusterlane-ob-step" id="clusterlane-ob-step-5" style="display:none;">
+                    <div class="clusterlane-ob-icon"><?php clusterlane_glyph_e( 'check' ); ?></div>
+                    <h2 class="clusterlane-ob-title"><?php esc_html_e( "You're all set!", 'clusterlane' ); ?></h2>
+                    <p class="clusterlane-ob-desc" id="clusterlane-ob-done-summary"></p>
+                    <p class="clusterlane-ob-desc"><?php esc_html_e( 'Start by exploring your clusters — see which articles are your pillars, which need more links, and where the gaps are.', 'clusterlane' ); ?></p>
+                    <div class="clusterlane-ob-actions">
+                        <a href="<?php echo esc_url( $ci_url ); ?>" class="clusterlane-action-btn clusterlane-action-btn--primary">
+                            <?php esc_html_e( 'Explore your clusters →', 'clusterlane' ); ?>
+                        </a>
+                        <button class="clusterlane-action-btn clusterlane-action-btn--secondary" id="clusterlane-ob-close" style="min-width:0;">
+                            <?php esc_html_e( 'Stay on Dashboard', 'clusterlane' ); ?>
+                        </button>
+                    </div>
+                </div>
+
+                <!-- Step indicator -->
+                <div class="clusterlane-ob-steps-indicator">
+                    <span class="clusterlane-ob-dot clusterlane-ob-dot--active" data-step="1"></span>
+                    <span class="clusterlane-ob-dot" data-step="2"></span>
+                    <span class="clusterlane-ob-dot" data-step="3"></span>
+                    <span class="clusterlane-ob-dot" data-step="4"></span>
+                    <span class="clusterlane-ob-dot" data-step="5"></span>
+                </div>
+
+            </div>
+        </div>
+        <?php endif; ?>
+        <?php
+    }
+
+    /* ------------------------------------------------------------------
+       AI Link Suggestions — filter/picker page
+    ------------------------------------------------------------------ */
+
+    public function render_link_suggest(): void {
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php $this->render_subnav( 'link-suggest' ); ?>
+
+            <div class="clusterlane-page-hero">
+                <?php clusterlane_icon_e( 'link' ); ?>
+                <div>
+                    <h1><?php esc_html_e( 'Link Suggestions', 'clusterlane' ); ?></h1>
+                    <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'Let AI draft anchor text and find the best placement for internal links.', 'clusterlane' ); ?></span>
+                </div>
+            </div>
+
+            <?php
+            // Premium: LinkSuggestPage renders the real tool; Free shows an example.
+            if ( \Clusterlane\Core\Edition::has( 'ai' ) && has_action( 'clusterlane_render_link_suggest' ) ) {
+                do_action( 'clusterlane_render_link_suggest' );
+            } else {
+                require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+                \Clusterlane\Admin\ProExamples::render_link_suggest();
+            }
+            ?>
+        </div>
+        <?php
+    }
+
+    /* ------------------------------------------------------------------
+       Settings (placeholder)
+    ------------------------------------------------------------------ */
+
+    /* ------------------------------------------------------------------
+       Text Match Scanner
+    ------------------------------------------------------------------ */
+
+    public function render_text_match(): void {
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php $this->render_subnav( 'text-match' ); ?>
+
+            <div class="clusterlane-page-hero">
+                <?php clusterlane_icon_e( 'text-match' ); ?>
+                <div>
+                    <h1><?php esc_html_e( 'Text Match', 'clusterlane' ); ?></h1>
+                    <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'Find unlinked mentions of your articles and turn them into internal links.', 'clusterlane' ); ?></span>
+                </div>
+            </div>
+
+            <div class="clusterlane-panel-card clusterlane-tm-setup" style="margin-bottom:20px;">
+                <div class="clusterlane-tm-question">
+                    <label for="clusterlane-tms-scan-cat"><?php esc_html_e( 'Find mentions in', 'clusterlane' ); ?></label>
+                    <select id="clusterlane-tms-scan-cat" class="clusterlane-tms-select">
+                        <option value="0"><?php esc_html_e( 'all articles', 'clusterlane' ); ?></option>
+                    </select>
+                    <label for="clusterlane-tms-match-cat"><?php esc_html_e( 'of articles in', 'clusterlane' ); ?></label>
+                    <select id="clusterlane-tms-match-cat" class="clusterlane-tms-select">
+                        <option value="0"><?php esc_html_e( 'any category', 'clusterlane' ); ?></option>
+                    </select>
+                </div>
+                <p class="clusterlane-tm-hint"><?php esc_html_e( 'Clusterlane looks for article titles written in your text without a link. Nothing changes until you click Apply.', 'clusterlane' ); ?></p>
+
+                <div class="clusterlane-tm-actions-bar">
+                    <button id="clusterlane-tms-start" class="clusterlane-action-btn clusterlane-action-btn--primary">
+                        <span class="clusterlane-action-btn__icon"><?php clusterlane_glyph_e( 'search' ); ?></span>
+                        <span class="clusterlane-action-btn__label"><?php esc_html_e( 'Find unlinked mentions', 'clusterlane' ); ?></span>
+                    </button>
+                    <button id="clusterlane-tms-stop" class="clusterlane-action-btn clusterlane-action-btn--tertiary" style="display:none;">
+                        <span class="clusterlane-action-btn__label"><?php esc_html_e( 'Stop', 'clusterlane' ); ?></span>
+                    </button>
+                    <button id="clusterlane-tms-clear" class="clusterlane-action-btn clusterlane-action-btn--tertiary" style="display:none;">
+                        <span class="clusterlane-action-btn__label"><?php esc_html_e( 'Clear results', 'clusterlane' ); ?></span>
+                    </button>
+                    <span id="clusterlane-tms-status" class="clusterlane-tm-status" aria-live="polite"></span>
+                    <button type="button" id="clusterlane-tms-clear-skipped" class="clusterlane-tm-linkbtn" style="display:none;"></button>
+                </div>
+
+                <div id="clusterlane-tms-progress-wrap" style="display:none;margin-top:14px;">
+                    <div class="clusterlane-progress-bar-wrap">
+                        <div class="clusterlane-progress-bar" id="clusterlane-tms-bar" style="width:0%;"></div>
+                    </div>
+                    <div id="clusterlane-tms-progress-label" class="clusterlane-tm-progress-label"></div>
+                </div>
+            </div>
+
+            <!-- Before the first scan: what a result looks like -->
+            <div class="clusterlane-tm-preview" id="clusterlane-tm-preview" aria-hidden="true">
+                <p class="clusterlane-tm-preview-label"><?php esc_html_e( 'What you will see', 'clusterlane' ); ?></p>
+                <div class="clusterlane-tm-group">
+                    <div class="clusterlane-tm-group-head"><span><?php esc_html_e( 'In', 'clusterlane' ); ?> <strong>Skansen</strong></span></div>
+                    <div class="clusterlane-tm-row">
+                        <div class="clusterlane-tm-row-main">
+                            <p class="clusterlane-tm-sentence">…an open-air museum on <span class="clusterlane-tm-hit">Djurgården</span>, next to Gröna Lund…</p>
+                            <p class="clusterlane-tm-target"><?php clusterlane_glyph_e( 'arrow' ); ?><?php esc_html_e( 'link to', 'clusterlane' ); ?> <strong>Djurgården</strong> <span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span>Orphan</span></p>
+                        </div>
+                        <div class="clusterlane-tm-row-actions"><span class="clusterlane-tm-btn clusterlane-tm-btn--apply"><?php esc_html_e( 'Apply', 'clusterlane' ); ?></span><span class="clusterlane-tm-btn"><?php esc_html_e( 'Skip', 'clusterlane' ); ?></span></div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="clusterlane-tm-summary" id="clusterlane-tm-summary" style="display:none;">
+                <span id="clusterlane-tm-summary-text"></span>
+                <div class="clusterlane-browse__roles" id="clusterlane-tm-filter" role="group" aria-label="<?php esc_attr_e( 'Show', 'clusterlane' ); ?>">
+                    <button type="button" data-f="all" class="is-active"><?php esc_html_e( 'All', 'clusterlane' ); ?> <em></em></button>
+                    <button type="button" data-f="orphan"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span><?php esc_html_e( 'To orphans', 'clusterlane' ); ?> <em></em></button>
+                </div>
+            </div>
+
+            <div id="clusterlane-tms-results"></div>
+        </div>
+        <?php
+    }
+
+    /* ------------------------------------------------------------------
+       Article Inspector
+    ------------------------------------------------------------------ */
+
+    public function render_cluster_inspector(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-cluster-inspector-page.php';
+        ( new \Clusterlane\Admin\ClusterInspectorPage() )->render();
+    }
+
+    public function render_seo_titles(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-seo-titles-page.php';
+        ( new \Clusterlane\Admin\SeoTitlesPage() )->render();
+    }
+
+    public function render_article_inspector(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-article-inspector-page.php';
+        ( new \Clusterlane\Admin\ArticleInspectorPage() )->render();
+    }
+
+    /* ------------------------------------------------------------------
+       Settings Page
+    ------------------------------------------------------------------ */
+
+    public function render_settings(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-settings-page.php';
+        ( new \Clusterlane\Admin\SettingsPage( $this->plugin ) )->render();
+    }
+
+    public function render_help(): void {
+        $admin_section = null;
+        if ( \Clusterlane\Core\Capabilities::can_manage() ) {
+            require_once CLUSTERLANE_DIR . 'intelligence/admin/class-support-panel.php';
+            $panel         = new \Clusterlane\Admin\SupportPanel( $this->plugin );
+            $admin_section = [ $panel, 'render' ];
+        }
+        clusterlane_render_help_page( $admin_section );
+    }
+
+    public function render_report(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-report-page.php';
+        ( new \Clusterlane\Admin\ReportPage() )->render();
+    }
+
+    public function render_cluster_settings(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-cluster-settings-page.php';
+        ( new \Clusterlane\Admin\ClusterSettingsPage( $this->plugin ) )->render();
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-article-inspector-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-article-inspector-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-article-inspector-page.php	(revision 3751027)
@@ -0,0 +1,215 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * ArticleInspectorPage
+ *
+ * Renders the Article Inspector admin page.
+ * All data loading is done via JS + REST API.
+ */
+class ArticleInspectorPage {
+
+    public function __construct() {}
+
+    public function render(): void {
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php clusterlane_render_sticky_bar( 'article-inspector' ); ?>
+
+            <!-- Inspector head: search, then the article itself as the headline -->
+            <section class="clusterlane-inspector-head" id="clusterlane-ai-head" aria-labelledby="clusterlane-ai-head-page">
+                <div class="clusterlane-inspector-head__bar">
+                    <?php clusterlane_icon_e( 'article' ); ?>
+                    <span class="clusterlane-inspector-head__page" id="clusterlane-ai-head-page"><?php esc_html_e( 'Article Inspector', 'clusterlane' ); ?></span>
+
+                    <div class="clusterlane-inspector-head__search">
+                        <svg class="clusterlane-inspector-head__search-icon" width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
+                            <circle cx="9" cy="9" r="6.5" stroke="currentColor" stroke-width="1.8"/>
+                            <path d="M18 18L14 14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+                        </svg>
+                        <input type="text" id="clusterlane-ai-post-search"
+                               class="clusterlane-inspector-head__search-input"
+                               placeholder="<?php esc_attr_e( 'Search articles by title…', 'clusterlane' ); ?>"
+                               aria-label="<?php esc_attr_e( 'Search articles by title', 'clusterlane' ); ?>"
+                               autocomplete="off">
+                        <div id="clusterlane-ai-search-results" class="clusterlane-ls-search-results clusterlane-inspector-head__results" style="display:none;"></div>
+                    </div>
+                </div>
+
+                <!-- Shown once an article is loaded (see CSS :has) -->
+                <div class="clusterlane-inspector-head__subject">
+                    <h1 id="clusterlane-ai-title" class="clusterlane-inspector-head__title"></h1>
+                    <div id="clusterlane-ai-post-meta" class="clusterlane-inspector-head__meta"></div>
+                    <div class="clusterlane-inspector-head__actions">
+                        <a id="clusterlane-ai-edit-link" href="#" target="_blank" class="clusterlane-inspector-head__btn clusterlane-inspector-head__btn--primary"><?php esc_html_e( 'Edit post', 'clusterlane' ); ?></a>
+                        <a id="clusterlane-ai-view-link" href="#" target="_blank" class="clusterlane-inspector-head__btn"><?php esc_html_e( 'View post', 'clusterlane' ); ?></a>
+                        <a href="<?php echo esc_url( admin_url( 'admin.php?page=clusterlane-article-inspector' ) ); ?>" class="clusterlane-inspector-head__btn clusterlane-inspector-head__btn--quiet"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'All articles', 'clusterlane' ); ?></a>
+                    </div>
+                </div>
+
+                <!-- Shown before an article is chosen -->
+                <div class="clusterlane-inspector-head__empty">
+                    <p><?php esc_html_e( 'Pick a published article to see its structure, where it sits in your clusters and which links it is missing.', 'clusterlane' ); ?></p>
+                    <div id="clusterlane-ai-recent-slot"></div>
+                </div>
+            </section>
+
+            <!-- Articles to review: paged, needs-attention-first list (hidden once an article is open) -->
+            <section class="clusterlane-panel-card clusterlane-browse" id="clusterlane-ai-browse" aria-labelledby="clusterlane-ab-title">
+                <div class="clusterlane-browse__head">
+                    <h3 class="clusterlane-ai-section-title" id="clusterlane-ab-title"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'Articles to review', 'clusterlane' ); ?></h3>
+                    <span class="clusterlane-browse__summary" id="clusterlane-ab-summary" aria-live="polite"></span>
+                    <a class="clusterlane-browse__seo-link" id="clusterlane-ab-seo" href="<?php echo esc_url( admin_url( 'admin.php?page=clusterlane-seo-titles' ) ); ?>"><?php clusterlane_glyph_e( 'pencil' ); ?><?php esc_html_e( 'Edit SEO titles', 'clusterlane' ); ?></a>
+                    <button type="button" class="clusterlane-csv-btn" id="clusterlane-ab-csv" title="<?php esc_attr_e( 'Download the list as a CSV file, with the filters you have set', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'download' ); ?><?php esc_html_e( 'Export CSV', 'clusterlane' ); ?></button>
+                </div>
+
+                <div class="clusterlane-browse__toolbar">
+                    <div class="clusterlane-browse__search">
+                        <?php clusterlane_glyph_e( 'search' ); ?>
+                        <input type="search" id="clusterlane-ab-q" placeholder="<?php esc_attr_e( 'Filter by title…', 'clusterlane' ); ?>" aria-label="<?php esc_attr_e( 'Filter articles by title', 'clusterlane' ); ?>" autocomplete="off">
+                    </div>
+                    <select id="clusterlane-ab-cluster" aria-label="<?php esc_attr_e( 'Cluster', 'clusterlane' ); ?>">
+                        <option value=""><?php esc_html_e( 'All clusters', 'clusterlane' ); ?></option>
+                    </select>
+                    <?php if ( \Clusterlane\Core\Lang::active() ) : // multilingual sites (0.98.168) ?>
+                    <select id="clusterlane-ab-lang" aria-label="<?php esc_attr_e( 'Language', 'clusterlane' ); ?>">
+                        <option value=""><?php esc_html_e( 'All languages', 'clusterlane' ); ?></option>
+                        <?php foreach ( \Clusterlane\Core\Lang::languages() as $code => $l ) : ?><option value="<?php echo esc_attr( $code ); ?>"><?php echo esc_html( $l['name'] ); ?></option><?php endforeach; ?>
+                    </select>
+                    <?php endif; ?>
+                    <select id="clusterlane-ab-meta" aria-label="<?php esc_attr_e( 'Meta description', 'clusterlane' ); ?>">
+                        <option value=""><?php esc_html_e( 'Any meta', 'clusterlane' ); ?></option>
+                        <option value="missing"><?php esc_html_e( 'Missing meta description', 'clusterlane' ); ?></option>
+                        <option value="weak"><?php esc_html_e( 'Missing or weak meta', 'clusterlane' ); ?></option>
+                    </select>
+                    <div class="clusterlane-browse__roles" id="clusterlane-ab-roles" role="group" aria-label="<?php esc_attr_e( 'Role', 'clusterlane' ); ?>">
+                        <button type="button" data-role="" class="is-active"><?php esc_html_e( 'All', 'clusterlane' ); ?></button>
+                        <button type="button" data-role="orphan"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span><?php esc_html_e( 'Orphans', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-role="light"><span class="clusterlane-role-dot clusterlane-role-dot--light"></span><?php esc_html_e( 'Light', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-role="support"><span class="clusterlane-role-dot clusterlane-role-dot--support"></span><?php esc_html_e( 'Support', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-role="pillar"><span class="clusterlane-role-dot clusterlane-role-dot--pillar"></span><?php esc_html_e( 'Pillars', 'clusterlane' ); ?> <em></em></button>
+                    </div>
+                </div>
+
+                <div class="clusterlane-browse__table-wrap">
+                    <table class="clusterlane-browse__table clusterlane-ai-table">
+                        <thead>
+                            <tr>
+                                <th class="clusterlane-browse__col-title"><button type="button" data-sort="title"><?php esc_html_e( 'Article', 'clusterlane' ); ?></button></th>
+                                <th><span id="clusterlane-ab-role-head"><?php esc_html_e( 'Role', 'clusterlane' ); ?></span><?php clusterlane_tip( 'role' ); ?></th>
+                                <th class="num"><button type="button" data-sort="score"><?php esc_html_e( 'Score', 'clusterlane' ); ?></button><?php clusterlane_tip( 'overall_score' ); ?></th>
+                                <th class="num"><button type="button" data-sort="inbound"><?php esc_html_e( 'In', 'clusterlane' ); ?></button><?php clusterlane_tip( 'inbound' ); ?></th>
+                                <th class="num clusterlane-browse__opt"><button type="button" data-sort="outbound"><?php esc_html_e( 'Out', 'clusterlane' ); ?></button><?php clusterlane_tip( 'outbound' ); ?></th>
+                                <th class="num clusterlane-browse__opt"><button type="button" data-sort="words"><?php esc_html_e( 'Words', 'clusterlane' ); ?></button></th>
+                                <th class="clusterlane-browse__opt"><button type="button" data-sort="scanned"><?php esc_html_e( 'Scanned', 'clusterlane' ); ?></button></th>
+                            </tr>
+                        </thead>
+                        <tbody id="clusterlane-ab-rows"></tbody>
+                    </table>
+                </div>
+
+                <div class="clusterlane-browse__pager" id="clusterlane-ab-pager"></div>
+            </section>
+
+            <!-- Inspector panel — hidden until a post is selected -->
+            <div id="clusterlane-ai-inspector" style="display:none; margin-top:20px;">
+
+                <!-- Vitals: the key numbers first, so the list and the AI text have context -->
+                <div class="clusterlane-ai-vitals" id="clusterlane-ai-vitals" aria-label="<?php esc_attr_e( 'Article at a glance', 'clusterlane' ); ?>"></div>
+
+                <!-- Improve this article — what to do here, most important first (computed, no AI) -->
+                <section class="clusterlane-panel-card clusterlane-ai-improve" id="clusterlane-ai-improve">
+                    <div class="clusterlane-ai-improve__head">
+                        <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'bolt' ); ?><?php esc_html_e( 'Improve this article', 'clusterlane' ); ?><?php clusterlane_tip( 'improve_article' ); ?></h3>
+                        <span class="clusterlane-ai-improve__sum" id="clusterlane-ai-improve-sum"></span>
+                    </div>
+                    <div class="clusterlane-ai-improve__list" id="clusterlane-ai-improve-list" aria-live="polite">
+                        <p class="clusterlane-ai-improve__loading"><?php esc_html_e( 'Checking the article…', 'clusterlane' ); ?></p>
+                    </div>
+                    <!-- More ideas from AI (Pro; example in Free) — inside Improve this article -->
+                    <div class="clusterlane-ai-analysis-card clusterlane-ai-improve__ai" id="clusterlane-ai-analysis-card">
+                        <div class="clusterlane-ai-analysis-header">
+                            <div class="clusterlane-ai-analysis-title-row">
+                                <h3 class="clusterlane-ai-section-title" style="margin:0;">
+                                    <?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'More ideas from AI', 'clusterlane' ); ?>
+                                </h3>
+                                <span class="clusterlane-ai-badge"><?php esc_html_e( 'AI', 'clusterlane' ); ?></span>
+                            </div>
+                            <div class="clusterlane-ai-analysis-actions">
+                                <button id="clusterlane-ai-analysis-btn" class="clusterlane-action-btn clusterlane-action-btn--primary" style="min-width:0;">
+                                    <?php clusterlane_glyph_e( 'sparkle' ); ?><span class="clusterlane-action-btn__label"><?php esc_html_e( 'Analyse article', 'clusterlane' ); ?></span>
+                                </button>
+                                <button id="clusterlane-ai-analysis-refresh" class="clusterlane-action-btn clusterlane-action-btn--tertiary" style="min-width:0; display:none;"
+                                        title="<?php esc_attr_e( 'Regenerate analysis', 'clusterlane' ); ?>">
+                                    <?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Regenerate', 'clusterlane' ); ?>
+                                </button>
+                            </div>
+                        </div>
+                        <p style="margin:8px 0 0; color:#64748b; font-size:12px;">
+                            <?php esc_html_e( 'AI-powered analysis of this article\'s health, improvement opportunities, possible sister articles, and cluster/link actions. Results are cached and invalidated when article data changes.', 'clusterlane' ); ?>
+                        </p>
+
+                        <div id="clusterlane-ai-analysis-status" style="margin-top:12px; font-size:.85rem; color:#64748b;"></div>
+                        <div id="clusterlane-ai-analysis-results" style="margin-top:16px;"></div>
+                    </div>
+                </section>
+
+                <h2 class="clusterlane-ai-details-head"><?php esc_html_e( 'The details behind it', 'clusterlane' ); ?></h2>
+
+                <!-- Article map — full width, first of the details -->
+                <div class="clusterlane-panel-card clusterlane-ai-graph-card" id="clusterlane-ai-graph-card" style="display:none;">
+                    <div class="clusterlane-ai-graph-header">
+                        <h3 class="clusterlane-ai-section-title" style="margin:0;">
+                            <?php clusterlane_glyph_e( 'map' ); ?><?php esc_html_e( 'Article map', 'clusterlane' ); ?><?php clusterlane_tip( 'graph_layout' ); ?>
+                        </h3>
+                        <div style="font-size:12px;color:#64748b;">
+                            <?php esc_html_e( 'Click any node to inspect that article.', 'clusterlane' ); ?>
+                        </div>
+                    </div>
+                    <!-- Tabs: this article's own links, then one tab per cluster it belongs to -->
+                    <div class="clusterlane-ai-graph-tabs" id="clusterlane-ai-graph-tabs" role="tablist" aria-label="<?php esc_attr_e( 'Graph', 'clusterlane' ); ?>"></div>
+                    <div class="clusterlane-ai-graph-wrap">
+                        <div id="clusterlane-ai-ego-graph"></div>
+                        <div class="clusterlane-ai-graph-overlay" id="clusterlane-ai-graph-overlay">
+                            <?php esc_html_e( 'Loading graph…', 'clusterlane' ); ?>
+                        </div>
+                    </div>
+                    <div class="clusterlane-ai-graph-legend" id="clusterlane-ai-graph-legend"></div>
+                </div>
+
+                <!-- The details: three even cards — what the vitals row doesn't already say -->
+                <div class="clusterlane-ai-trio">
+                    <div class="clusterlane-panel-card clusterlane-ai-trio__card">
+                        <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'chart' ); ?><?php esc_html_e( 'Content', 'clusterlane' ); ?></h3>
+                        <div id="clusterlane-ai-metrics">
+                                <p class="clusterlane-ai-no-data"><?php esc_html_e( 'No scan data yet — click Scan in the bar at the top.', 'clusterlane' ); ?></p>
+                        </div>
+                    </div>
+                    <div class="clusterlane-panel-card clusterlane-ai-trio__card">
+                        <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'link' ); ?><?php esc_html_e( 'Internal links', 'clusterlane' ); ?></h3>
+                        <div id="clusterlane-ai-links-summary" class="clusterlane-ai-links-summary"></div>
+
+                            <details class="clusterlane-ai-links-details" id="clusterlane-ai-inbound-details">
+                                <summary><?php esc_html_e( 'Posts linking here', 'clusterlane' ); ?><?php clusterlane_tip( 'inbound' ); ?> <span id="clusterlane-ai-inbound-count" class="clusterlane-ai-count-badge">0</span></summary>
+                                <ul id="clusterlane-ai-inbound-list" class="clusterlane-ai-link-list"></ul>
+                            </details>
+
+                            <details class="clusterlane-ai-links-details" id="clusterlane-ai-outbound-details">
+                                <summary><?php esc_html_e( 'Posts this links to', 'clusterlane' ); ?><?php clusterlane_tip( 'outbound' ); ?> <span id="clusterlane-ai-outbound-count" class="clusterlane-ai-count-badge">0</span></summary>
+                                <ul id="clusterlane-ai-outbound-list" class="clusterlane-ai-link-list"></ul>
+                            </details>
+                    </div>
+                    <div class="clusterlane-panel-card clusterlane-ai-trio__card">
+                        <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'nodes' ); ?><?php esc_html_e( 'Cluster membership', 'clusterlane' ); ?><?php clusterlane_tip( 'role' ); ?></h3>
+                        <div id="clusterlane-ai-clusters"></div>
+                    </div>
+                </div>
+
+
+            </div><!-- /clusterlane-ai-inspector -->
+
+        </div><!-- /.clusterlane-wrap -->
+        <?php
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-inspector-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-inspector-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-inspector-page.php	(revision 3751027)
@@ -0,0 +1,277 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+class ClusterInspectorPage {
+
+    public function __construct() {}
+
+    public function render(): void {
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php clusterlane_render_sticky_bar( 'cluster-inspector' ); ?>
+
+            <div class="clusterlane-ci-hero">
+                <div class="clusterlane-ci-hero-title">
+                    <?php clusterlane_icon_e( 'cluster' ); ?>
+                    <div>
+                        <h1><?php esc_html_e( 'Cluster Inspector', 'clusterlane' ); ?></h1>
+                        <span style="color:rgba(255,255,255,.6);font-size:.8rem;"><?php esc_html_e( 'Explore how your content clusters connect and flow into each other.', 'clusterlane' ); ?></span>
+                    </div>
+                </div>
+                <div class="clusterlane-ci-hero-stats" id="clusterlane-ci-hero-stats">
+                    <div class="clusterlane-ci-stat"><span class="clusterlane-ci-stat-value" id="clusterlane-ci-stat-clusters">—</span><span class="clusterlane-ci-stat-label"><?php esc_html_e( 'Clusters', 'clusterlane' ); ?></span></div>
+                    <div class="clusterlane-ci-stat"><span class="clusterlane-ci-stat-value" id="clusterlane-ci-stat-posts">—</span><span class="clusterlane-ci-stat-label"><?php esc_html_e( 'Articles', 'clusterlane' ); ?></span></div>
+                    <div class="clusterlane-ci-stat"><span class="clusterlane-ci-stat-value" id="clusterlane-ci-stat-health">—</span><span class="clusterlane-ci-stat-label"><?php esc_html_e( 'Avg health', 'clusterlane' ); ?><?php clusterlane_tip( 'health' ); ?></span></div>
+                    <div class="clusterlane-ci-stat"><span class="clusterlane-ci-stat-value" id="clusterlane-ci-stat-pillars">—</span><span class="clusterlane-ci-stat-label"><?php esc_html_e( 'Pillar articles', 'clusterlane' ); ?><?php clusterlane_tip( 'pillars' ); ?></span></div>
+                    <div class="clusterlane-ci-stat"><span class="clusterlane-ci-stat-value" id="clusterlane-ci-stat-orphans">—</span><span class="clusterlane-ci-stat-label"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?><?php clusterlane_tip( 'orphans' ); ?></span></div>
+                </div>
+            </div>
+
+            <!-- Focus next: where a few links make the biggest difference (computed, no AI) -->
+            <section class="clusterlane-panel-card clusterlane-ci-focus" id="clusterlane-ci-focus" style="display:none;">
+                <div class="clusterlane-ci-focus-head">
+                    <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'target' ); ?><?php esc_html_e( 'Focus next', 'clusterlane' ); ?><?php clusterlane_tip( 'focus_next' ); ?></h3>
+                    <span class="clusterlane-collapse-sub"><?php esc_html_e( 'Clusters where a few links make the biggest difference', 'clusterlane' ); ?></span>
+                </div>
+                <div class="clusterlane-ci-focus-grid" id="clusterlane-ci-focus-grid"></div>
+            </section>
+
+            <!-- Clusters: one card, two views (List | Map) sharing the same filters -->
+            <details class="clusterlane-panel-card clusterlane-collapse" id="clusterlane-ci-list-card" data-collapse-key="ci-list" open>
+                <summary class="clusterlane-collapse-head">
+                    <h3 class="clusterlane-ai-section-title"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'Clusters', 'clusterlane' ); ?></h3>
+                    <span id="clusterlane-ci-cluster-count" class="clusterlane-collapse-count"></span>
+                </summary>
+                <div class="clusterlane-collapse-body">
+                    <div class="clusterlane-toolbar clusterlane-toolbar--rows" id="clusterlane-ci-toolbar">
+                        <div class="clusterlane-toolbar-row">
+                        <div class="clusterlane-seg clusterlane-ci-view" id="clusterlane-ci-view" role="group" aria-label="<?php esc_attr_e( 'View', 'clusterlane' ); ?>">
+                            <button type="button" class="clusterlane-seg-btn is-active" data-view="list" aria-pressed="true"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'List', 'clusterlane' ); ?></button>
+                            <button type="button" class="clusterlane-seg-btn" data-view="map" aria-pressed="false"><?php clusterlane_glyph_e( 'globe' ); ?><?php esc_html_e( 'Map', 'clusterlane' ); ?></button>
+                        </div>
+                        <div class="clusterlane-ci-group" id="clusterlane-ci-group" hidden>
+                            <span class="clusterlane-toolbar-sep"></span>
+                            <span class="clusterlane-toolbar-label"><?php esc_html_e( 'Group', 'clusterlane' ); ?><?php clusterlane_tip( 'sections' ); ?></span>
+                            <div class="clusterlane-seg" role="group" aria-label="<?php esc_attr_e( 'Group clusters', 'clusterlane' ); ?>">
+                                <button type="button" class="clusterlane-seg-btn is-active" data-group="section" aria-pressed="true"><?php esc_html_e( 'Sections', 'clusterlane' ); ?></button>
+                                <button type="button" class="clusterlane-seg-btn" data-group="none" aria-pressed="false"><?php esc_html_e( 'None', 'clusterlane' ); ?></button>
+                            </div>
+                            <button type="button" class="clusterlane-tm-btn clusterlane-ci-new-group" id="clusterlane-ci-new-group"><?php esc_html_e( '+ New group', 'clusterlane' ); ?></button>
+                        </div>
+                        <button type="button" class="clusterlane-csv-btn" id="clusterlane-ci-csv" title="<?php esc_attr_e( 'Download the list as a CSV file, with the filters you have set', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'download' ); ?><?php esc_html_e( 'Export CSV', 'clusterlane' ); ?></button>
+                        </div>
+                        <div class="clusterlane-toolbar-row">
+                        <div class="clusterlane-search-wrap clusterlane-toolbar-search">
+                            <svg class="clusterlane-search-icon" width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
+                                <circle cx="9" cy="9" r="6.5" stroke="currentColor" stroke-width="1.8"/>
+                                <path d="M18 18L14 14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
+                            </svg>
+                            <input type="text" id="clusterlane-ci-search" class="clusterlane-search-input" placeholder="<?php esc_attr_e( 'Filter clusters…', 'clusterlane' ); ?>">
+                        </div>
+                        <span class="clusterlane-toolbar-sep"></span>
+                        <div class="clusterlane-browse__roles" id="clusterlane-ci-bands" role="group" aria-label="<?php esc_attr_e( 'Health', 'clusterlane' ); ?>">
+                            <button type="button" data-band="" class="is-active"><?php esc_html_e( 'All', 'clusterlane' ); ?></button>
+                            <button type="button" data-band="weak"><span class="clusterlane-role-dot clusterlane-band-dot--weak"></span><?php esc_html_e( 'Weak', 'clusterlane' ); ?> <em></em></button>
+                            <button type="button" data-band="fair"><span class="clusterlane-role-dot clusterlane-band-dot--fair"></span><?php esc_html_e( 'Fair', 'clusterlane' ); ?> <em></em></button>
+                            <button type="button" data-band="good"><span class="clusterlane-role-dot clusterlane-band-dot--good"></span><?php esc_html_e( 'Good', 'clusterlane' ); ?> <em></em></button>
+                        </div>
+                        <span class="clusterlane-toolbar-sep"></span>
+                        <span class="clusterlane-toolbar-label"><?php esc_html_e( 'Type', 'clusterlane' ); ?></span>
+                        <div class="clusterlane-type-filters clusterlane-seg">
+                            <button type="button" class="clusterlane-type-filter clusterlane-seg-btn" data-type="category" title="<?php esc_attr_e( 'Categories', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'folder' ); ?></button>
+                            <button type="button" class="clusterlane-type-filter clusterlane-seg-btn" data-type="post_tag" title="<?php esc_attr_e( 'Tags', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'tag' ); ?></button>
+                            <button type="button" class="clusterlane-type-filter clusterlane-seg-btn" data-type="custom" title="<?php esc_attr_e( 'Custom', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'pencil' ); ?></button>
+                            <?php if ( \Clusterlane\Core\Shop::active() ) : ?><button type="button" class="clusterlane-type-filter clusterlane-seg-btn" data-type="product_cat" title="<?php esc_attr_e( 'Product categories', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'bag' ); ?></button><?php endif; ?>
+                            <?php foreach ( \Clusterlane\Core\ClusterTypes::for_js() as $ctype => $cinfo ) : if ( in_array( $ctype, \Clusterlane\Core\ClusterTypes::BUILT_IN, true ) ) continue; ?><button type="button" class="clusterlane-type-filter clusterlane-seg-btn" data-type="<?php echo esc_attr( $ctype ); ?>" title="<?php echo esc_attr( $cinfo['plural'] ); ?>"><?php clusterlane_glyph_e( $cinfo['glyph'] ); ?><span class="clusterlane-type-filter__txt"><?php echo esc_html( $cinfo['plural'] ); ?></span></button><?php endforeach; ?>
+                        </div>
+                        </div>
+                    </div>
+                    <div class="clusterlane-browse__table-wrap" id="clusterlane-ci-table-wrap">
+                        <table class="clusterlane-browse__table clusterlane-ci-table">
+                            <thead>
+                                <tr>
+                                    <th class="clusterlane-browse__col-title"><button type="button" data-sort="label"><?php esc_html_e( 'Cluster', 'clusterlane' ); ?></button></th>
+                                    <th class="clusterlane-browse__opt"><button type="button" data-sort="archetype"><?php esc_html_e( 'Archetype', 'clusterlane' ); ?></button><?php clusterlane_tip( 'archetype' ); ?></th>
+                                    <th class="num"><button type="button" data-sort="members"><?php esc_html_e( 'Articles', 'clusterlane' ); ?></button></th>
+                                    <th class="num"><button type="button" data-sort="orphans"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?></button><?php clusterlane_tip( 'orphans' ); ?></th>
+                                    <th class="num clusterlane-browse__opt"><button type="button" data-sort="pillars"><?php esc_html_e( 'Pillars', 'clusterlane' ); ?></button><?php clusterlane_tip( 'pillars' ); ?></th>
+                                    <th class="num"><button type="button" data-sort="health"><?php esc_html_e( 'Health', 'clusterlane' ); ?></button><?php clusterlane_tip( 'health' ); ?></th>
+                                </tr>
+                            </thead>
+                            <tbody id="clusterlane-ci-cluster-list"></tbody>
+                        </table>
+                    </div>
+                    <div class="clusterlane-ci-map" id="clusterlane-ci-map-wrap" style="display:none;">
+                        <p class="clusterlane-ci-map-note"><span><?php esc_html_e( 'Node size = articles · Line width = links between clusters · Click a cluster to inspect it', 'clusterlane' ); ?><?php clusterlane_tip( 'flow_map' ); ?></span><span id="clusterlane-ci-map-cap"></span></p>
+                        <div style="position:relative;">
+                            <div id="clusterlane-ci-flow-graph" style="width:100%;height:460px;border:1px solid #e2e8f0;border-radius:8px;background:#fafbfc;"></div>
+                            <div id="clusterlane-ci-flow-overlay" class="clusterlane-ai-graph-overlay">
+                                <span id="clusterlane-ci-flow-status" style="color:#64748b;font-size:13px;"><?php esc_html_e( 'Loading…', 'clusterlane' ); ?></span>
+                            </div>
+                        </div>
+                        <div id="clusterlane-ci-flow-legend" class="clusterlane-graph-legend" style="margin-top:10px;"></div>
+                    </div>
+                </div>
+            </details>
+
+            <!-- Inline cluster detail (shown below list when a cluster is selected) -->
+            <div id="clusterlane-ci-detail-panel" style="display:none;margin-top:16px;">
+                <div class="clusterlane-panel-card" style="padding:0;overflow:hidden;">
+                    <!-- Detail header -->
+                    <div class="clusterlane-ci-detail-header" id="clusterlane-ci-detail-header">
+                        <div class="clusterlane-ci-detail-title-row">
+                            <div class="clusterlane-ci-detail-titles">
+                                <span class="clusterlane-ci-detail-kind" id="clusterlane-ci-detail-kind"></span>
+                                <h2 id="clusterlane-ci-detail-label" class="clusterlane-ci-detail-label"></h2>
+                            </div>
+                            <div class="clusterlane-ci-detail-health" id="clusterlane-ci-detail-health" hidden></div>
+                            <div class="clusterlane-ci-detail-actions">
+                                <button id="clusterlane-ci-ai-btn" class="clusterlane-inspector-head__btn clusterlane-ci-detail-ai-btn">
+                                    <?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Analyse cluster', 'clusterlane' ); ?>
+                                </button>
+                                <button id="clusterlane-ci-detail-close" class="clusterlane-ci-detail-close" title="<?php esc_attr_e( 'Close', 'clusterlane' ); ?>">×</button>
+                            </div>
+                        </div>
+                        <div id="clusterlane-ci-graph-infobar"></div>
+                        <div id="clusterlane-ci-detail-stats" style="display:none;"></div>
+                    </div>
+
+                    <!-- Detail body -->
+                    <div style="padding:20px 24px;">
+                        <!-- Class distribution bar -->
+                        <div id="clusterlane-ci-detail-distbar" style="margin-bottom:20px;"></div>
+
+                        <!-- Cluster graph -->
+                        <div id="clusterlane-ci-detail-graph-wrap" style="margin-bottom:24px;">
+                            <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
+                                <h4 style="margin:0;font-size:13px;font-weight:600;color:#1e293b;"><?php clusterlane_glyph_e( 'chart' ); ?><?php esc_html_e( 'Cluster graph', 'clusterlane' ); ?><?php clusterlane_tip( 'graph_layout' ); ?></h4>
+                                <div style="display:flex;align-items:center;gap:10px;">
+                                    <div id="clusterlane-ci-graph-legend" style="display:flex;gap:10px;flex-wrap:wrap;"></div>
+                                    <button id="clusterlane-ci-graph-expand-btn" class="clusterlane-action-btn clusterlane-action-btn--secondary" style="font-size:11px;padding:4px 10px;min-width:0;">⤢ <?php esc_html_e( 'Expand', 'clusterlane' ); ?></button>
+                                </div>
+                            </div>
+                            <div style="position:relative;">
+                                <div id="clusterlane-ci-detail-graph" style="width:100%;height:360px;border:1px solid #e2e8f0;border-radius:8px;background:#fafbfc;"></div>
+                                <div id="clusterlane-ci-graph-overlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border-radius:8px;background:rgba(250,251,252,.85);">
+                                    <span id="clusterlane-ci-graph-status" style="color:#64748b;font-size:13px;"><?php esc_html_e( 'Loading…', 'clusterlane' ); ?></span>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- AI analysis result (hidden until loaded) -->
+                        <div id="clusterlane-ci-ai-result" style="display:none;margin-bottom:24px;"></div>
+
+                        <!-- Improve this cluster -->
+                        <div id="clusterlane-ci-improve" class="clusterlane-improve-panel" style="display:none;">
+                            <div class="clusterlane-improve-panel-header">
+                                <div class="clusterlane-improve-panel-title">
+                                    <span class="clusterlane-improve-panel-icon"><?php clusterlane_glyph_e( 'bolt' ); ?></span>
+                                    <div>
+                                        <div class="clusterlane-improve-panel-heading"><?php esc_html_e( 'Improve this cluster', 'clusterlane' ); ?></div>
+                                        <div class="clusterlane-improve-panel-sub"><?php esc_html_e( 'AI-guided actions to strengthen this cluster — review and apply, you stay in control.', 'clusterlane' ); ?></div>
+                                    </div>
+                                </div>
+                            </div>
+                            <div class="clusterlane-improve-panel-body">
+                            <!-- What to do first (filled in as the blocks load) -->
+                            <div class="clusterlane-improve-summary" id="clusterlane-improve-summary" aria-live="polite"></div>
+                            <div class="clusterlane-improve-block" id="clusterlane-improve-tms">
+                                <div class="clusterlane-improve-block-header" data-block="tms" role="button" tabindex="0" aria-controls="clusterlane-improve-tms-body">
+                                    <span class="clusterlane-improve-block-label">
+                                        <span class="clusterlane-improve-block-icon"><?php clusterlane_glyph_e( 'link' ); ?></span>
+                                        <span class="clusterlane-improve-block-titles"><span class="clusterlane-improve-block-title"><?php esc_html_e( 'Quick wins: unlinked mentions', 'clusterlane' ); ?></span><span class="clusterlane-improve-block-sub"><?php esc_html_e( 'Article titles already written in this cluster’s text — link them in one click.', 'clusterlane' ); ?></span></span>
+                                    </span>
+                                    <span class="clusterlane-improve-block-meta">
+                                        <span class="clusterlane-improve-count" id="clusterlane-improve-tms-count" style="display:none;"></span>
+                                        <span class="clusterlane-improve-chevron">&#x25BE;</span>
+                                    </span>
+                                </div>
+                                <div class="clusterlane-improve-block-body" id="clusterlane-improve-tms-body">
+                                    <div class="clusterlane-improve-loading"><?php esc_html_e( 'Looking for mentions…', 'clusterlane' ); ?></div>
+                                </div>
+                            </div>
+                            <div class="clusterlane-improve-block" id="clusterlane-improve-ailink">
+                                <div class="clusterlane-improve-block-header" data-block="ailink" role="button" tabindex="0" aria-controls="clusterlane-improve-ailink-body">
+                                    <span class="clusterlane-improve-block-label">
+                                        <span class="clusterlane-improve-block-icon"><?php clusterlane_glyph_e( 'sparkle' ); ?></span>
+                                        <span class="clusterlane-improve-block-titles"><span class="clusterlane-improve-block-title"><?php esc_html_e( 'Link articles that need it', 'clusterlane' ); ?></span><span class="clusterlane-improve-block-sub"><?php esc_html_e( 'Orphans first — AI writes a sentence with the link in the best-matching article.', 'clusterlane' ); ?></span></span>
+                                    </span>
+                                    <span class="clusterlane-improve-block-meta">
+                                        <span class="clusterlane-improve-count" id="clusterlane-improve-ailink-count" style="display:none;"></span>
+                                        <span class="clusterlane-improve-chevron">&#x25BE;</span>
+                                    </span>
+                                </div>
+                                <div class="clusterlane-improve-block-body" id="clusterlane-improve-ailink-body">
+                                    <div class="clusterlane-improve-loading"><?php esc_html_e( 'Finding the best articles to link from…', 'clusterlane' ); ?></div>
+                                </div>
+                            </div>
+                            <div class="clusterlane-improve-block" id="clusterlane-improve-gap">
+                                <div class="clusterlane-improve-block-header" data-block="gap" role="button" tabindex="0" aria-controls="clusterlane-improve-gap-body">
+                                    <span class="clusterlane-improve-block-label">
+                                        <span class="clusterlane-improve-block-icon"><?php clusterlane_glyph_e( 'bulb' ); ?></span>
+                                        <span class="clusterlane-improve-block-titles"><span class="clusterlane-improve-block-title"><?php esc_html_e( 'Missing content', 'clusterlane' ); ?></span><span class="clusterlane-improve-block-sub"><?php esc_html_e( 'Articles this cluster doesn’t have yet — AI suggests them and can write a brief or a draft.', 'clusterlane' ); ?></span></span>
+                                    </span>
+                                    <span class="clusterlane-improve-block-meta">
+                                        <span class="clusterlane-improve-count" id="clusterlane-improve-gap-count" style="display:none;"></span>
+                                        <span class="clusterlane-improve-chevron">&#x25BE;</span>
+                                    </span>
+                                </div>
+                                <div class="clusterlane-improve-block-body" id="clusterlane-improve-gap-body">
+                                    <div class="clusterlane-improve-loading"><?php esc_html_e( 'Checking for saved suggestions…', 'clusterlane' ); ?></div>
+                                </div>
+                            </div>
+                            <div class="clusterlane-improve-block" id="clusterlane-improve-split" hidden>
+                                <div class="clusterlane-improve-block-header" data-block="split" role="button" tabindex="0" aria-controls="clusterlane-improve-split-body">
+                                    <span class="clusterlane-improve-block-label">
+                                        <span class="clusterlane-improve-block-icon"><?php clusterlane_glyph_e( 'nodes' ); ?></span>
+                                        <span class="clusterlane-improve-block-titles"><span class="clusterlane-improve-block-title"><?php esc_html_e( 'Pillar & split check', 'clusterlane' ); ?></span><span class="clusterlane-improve-block-sub"><?php esc_html_e( 'Too broad, or no natural main article? AI checks the pillar and suggests smaller clusters — you decide what to create.', 'clusterlane' ); ?></span></span>
+                                    </span>
+                                    <span class="clusterlane-improve-block-meta">
+                                        <span class="clusterlane-improve-count clusterlane-improve-count--split" id="clusterlane-improve-split-count" style="display:none;"></span>
+                                        <span class="clusterlane-improve-chevron">&#x25BE;</span>
+                                    </span>
+                                </div>
+                                <div class="clusterlane-improve-block-body" id="clusterlane-improve-split-body"></div>
+                            </div>
+                            </div>
+                        </div>
+
+                        <!-- Member table — visibly part of THIS cluster, not the cluster list -->
+                        <div class="clusterlane-ci-members-box">
+                            <h4 class="clusterlane-ci-members-title"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'Articles in this cluster', 'clusterlane' ); ?> <span id="clusterlane-ci-members-count"></span></h4>
+                            <p class="clusterlane-ci-members-hint"><?php esc_html_e( 'Click an article to open it in Article Inspector.', 'clusterlane' ); ?></p>
+                            <div id="clusterlane-ci-detail-members"></div>
+                        </div>
+                        <!-- Back to the list (0.98.187): the cluster opens inside the table, so the rest sits far below -->
+                        <p class="clusterlane-ci-detail-back"><button type="button" class="clusterlane-tm-btn" id="clusterlane-ci-detail-back"><?php clusterlane_glyph_e( 'arrow' ); ?><?php esc_html_e( 'Close and back to all clusters', 'clusterlane' ); ?></button></p>
+                    </div>
+                </div>
+            </div>
+
+        </div><!-- /.clusterlane-wrap -->
+
+        <!-- Graph expand modal (shared, one instance) -->
+        <div id="clusterlane-ci-graph-modal" style="display:none;" class="clusterlane-ci-graph-modal-overlay" role="dialog" aria-modal="true">
+            <div class="clusterlane-ci-graph-modal-inner">
+                <div class="clusterlane-ci-graph-modal-header">
+                    <div style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:0;">
+                        <span id="clusterlane-ci-graph-modal-title" class="clusterlane-ci-graph-modal-title"></span><?php clusterlane_tip( 'graph_layout' ); ?>
+                        <div id="clusterlane-ci-graph-modal-infobar"></div>
+                    </div>
+                    <div style="display:flex;align-items:center;gap:10px;flex-shrink:0;margin-left:16px;">
+                        <div id="clusterlane-ci-graph-modal-legend" class="clusterlane-graph-legend"></div>
+                        <button id="clusterlane-ci-graph-modal-close" class="clusterlane-ci-detail-close" title="<?php esc_attr_e( 'Close', 'clusterlane' ); ?>" style="position:static;font-size:1.2rem;line-height:1;">×</button>
+                    </div>
+                </div>
+                <div class="clusterlane-ci-graph-modal-body">
+                    <div id="clusterlane-ci-graph-modal-canvas" class="clusterlane-ci-graph-modal-canvas"></div>
+                    <div id="clusterlane-ci-graph-modal-right" class="clusterlane-ci-graph-modal-right">
+                        <p class="clusterlane-np__empty"><?php clusterlane_glyph_e( 'nodes' ); ?><?php esc_html_e( 'Click an article in the graph to see its links.', 'clusterlane' ); ?></p>
+                    </div>
+                </div>
+            </div>
+        </div>
+        <?php
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-settings-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-settings-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-cluster-settings-page.php	(revision 3751027)
@@ -0,0 +1,603 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * ClusterSettingsPage
+ *
+ * One place to decide which individual categories and tags should
+ * become clusters, plus custom (manually assembled) clusters — pulled
+ * together from what used to be scattered across the main Settings
+ * page (whole-taxonomy on/off) and a form on the Dashboard (custom
+ * clusters), matching how the person's previous plugin organized this.
+ */
+class ClusterSettingsPage {
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function render(): void {
+        $settings = $this->plugin->get( 'settings' );
+        $engine   = $this->plugin->get( 'cluster_engine' );
+
+        if ( isset( $_POST['clusterlane_save_cluster_settings'] ) && \Clusterlane\Core\Capabilities::can_manage() && check_admin_referer( 'clusterlane_cluster_settings_save' ) ) {
+            $this->save( $settings, $engine );
+            echo '<div class="notice notice-success is-dismissible"><p>' .
+                esc_html__( 'Cluster settings saved — clusters have been rebuilt with your new selection.', 'clusterlane' ) .
+                '</p></div>';
+        }
+
+        $min_count  = max( 2, $settings->min_term_count() );
+        $categories = $this->get_clusterable_categories( $min_count );
+        $tags       = $this->get_clusterable_tags( $min_count );
+        $unscanned  = $this->unscanned_terms( array_merge( $categories, $tags ) );
+        // WooCommerce (0.98.172): product categories as clusters.
+        $shop       = \Clusterlane\Core\Shop::active();
+        $pcats      = $shop ? $this->get_clusterable_product_cats( $min_count ) : [];
+        $selected_pcats = $settings->enabled_product_cats();
+        $overlap    = $this->get_tag_category_overlap( $tags );
+        // Other taxonomies of the site's content (0.98.177): attraction categories,
+        // regions, genres … — one card each, the hierarchical ones first.
+        $extra_taxes   = \Clusterlane\Core\ClusterTypes::extra_taxonomies( $this->public_post_types() );
+        $extra_terms   = [];
+        foreach ( $extra_taxes as $tax_name => $tax_obj ) {
+            $list = get_terms( [ 'taxonomy' => $tax_name, 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] );
+            $list = is_wp_error( $list ) ? [] : array_values( array_filter( $list, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) );
+            if ( $list ) $extra_terms[ $tax_name ] = $list;
+        }
+        $selected_extra = $settings->enabled_terms();
+        if ( $extra_terms ) $unscanned += $this->unscanned_terms( array_merge( ...array_values( $extra_terms ) ) );
+
+        // Most promising cluster candidates first: cross-cutting tags
+        // (low overlap with any single category) before redundant ones,
+        // and — for similar overlap — bigger tags before smaller ones.
+        usort( $tags, function ( $a, $b ) use ( $overlap ) {
+            $oa = $overlap[ $a->term_id ]['overlap_pct'] ?? 0;
+            $ob = $overlap[ $b->term_id ]['overlap_pct'] ?? 0;
+            if ( $oa !== $ob ) return $oa <=> $ob;
+            return (int) $b->count <=> (int) $a->count;
+        } );
+
+        $configured = $settings->cluster_terms_configured();
+        // Before the user has ever saved a selection here, default to
+        // "everything that currently qualifies" so the page reflects
+        // the plugin's existing out-of-the-box behavior rather than
+        // looking like nothing is selected.
+        $selected_categories = $configured
+            ? $settings->enabled_categories()
+            : array_map( fn( $t ) => (int) $t->term_id, $categories );
+        // Unconfigured: tags only count as selected if tag clusters are actually
+        // being built (Tags ticked under Settings → Scan Settings), so the page
+        // shows what really happens.
+        $selected_tags = $configured
+            ? $settings->enabled_tags()
+            : ( in_array( 'post_tag', $settings->taxonomies(), true ) ? array_map( fn( $t ) => (int) $t->term_id, $tags ) : [] );
+
+        $custom_clusters = array_values( array_filter(
+            $engine ? $engine->get_clusters() : [],
+            fn( $c ) => $c->cluster_type === 'custom'
+        ) );
+
+        // Current state of every cluster (for the status next to each term).
+        $by_key = [];
+        foreach ( ( $engine ? $engine->get_clusters() : [] ) as $c ) $by_key[ $c->cluster_key ] = $c;
+        global $wpdb;
+        $orphans_by_key = [];
+        foreach ( (array) $wpdb->get_results( "SELECT cluster_key, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE content_class = 'orphan' GROUP BY cluster_key" ) as $r ) { // phpcs:ignore
+            $orphans_by_key[ $r->cluster_key ] = (int) $r->n;
+        }
+        $band   = fn( int $h ): string => $h >= 70 ? 'good' : ( $h >= 50 ? 'fair' : 'weak' );
+        $status = function ( string $key, bool $selected ) use ( $by_key, $band ): string {
+            $c = $by_key[ $key ] ?? null;
+            if ( $selected && ( ! $c || ! (int) $c->is_active ) ) return '<span class="clusterlane-ca-status clusterlane-ca-status--off">' . esc_html__( 'Not built yet — save to add', 'clusterlane' ) . '</span>';
+            if ( ! $selected || ! $c || ! (int) $c->is_active ) return '<span class="clusterlane-ca-status clusterlane-ca-status--off">' . '' . '</span>';
+            $h = (int) $c->health_score;
+            return '<span class="clusterlane-ca-status"><span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--' . $band( $h ) . '"><span class="clusterlane-ci-health-node" aria-hidden="true"></span>' . $h . '%</span></span>';
+        };
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php if ( function_exists( 'clusterlane_render_sticky_bar' ) ) clusterlane_render_sticky_bar( 'cluster-settings' ); ?>
+
+            <div class="clusterlane-page-hero">
+                <?php clusterlane_icon_e( 'admin' ); ?>
+                <div>
+                    <h1><?php esc_html_e( 'Cluster Admin', 'clusterlane' ); ?></h1>
+                    <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'Decide what counts as a cluster: your categories, your tags and your own hand-picked groups of articles.', 'clusterlane' ); ?></span>
+                </div>
+            </div>
+
+            <!-- Three cluster types, explained up front -->
+            <div class="clusterlane-type-explainer">
+                <div class="clusterlane-type-card">
+                    <span class="clusterlane-type-icon"><?php clusterlane_glyph_e( 'folder' ); ?></span>
+                    <div>
+                        <strong><?php esc_html_e( 'Category clusters', 'clusterlane' ); ?></strong>
+                        <p><?php esc_html_e( 'One cluster per WordPress category you pick. Kept up to date automatically as you publish.', 'clusterlane' ); ?></p>
+                    </div>
+                </div>
+                <div class="clusterlane-type-card">
+                    <span class="clusterlane-type-icon"><?php clusterlane_glyph_e( 'tag' ); ?></span>
+                    <div>
+                        <strong><?php esc_html_e( 'Tag clusters', 'clusterlane' ); ?></strong>
+                        <p><?php esc_html_e( 'One cluster per tag you pick. Best for themes that run across several categories.', 'clusterlane' ); ?></p>
+                    </div>
+                </div>
+                <div class="clusterlane-type-card">
+                    <span class="clusterlane-type-icon"><?php clusterlane_glyph_e( 'pencil' ); ?></span>
+                    <div>
+                        <strong><?php esc_html_e( 'Custom clusters', 'clusterlane' ); ?></strong>
+                        <p><?php esc_html_e( 'Articles you pick by hand — for a topic that has no category or tag of its own.', 'clusterlane' ); ?></p>
+                    </div>
+                </div>
+            </div>
+
+            <!-- ============ Cluster structure check (0.98.141) ============ -->
+            <div class="clusterlane-panel-card clusterlane-panel-card--wide clusterlane-review-card">
+                <span class="clusterlane-review-card-icon"><?php clusterlane_glyph_e( 'nodes' ); ?></span>
+                <div class="clusterlane-review-card-text">
+                    <strong><?php esc_html_e( 'Check the whole cluster structure', 'clusterlane' ); ?></strong>
+                    <p><?php esc_html_e( 'AI goes through every cluster: which ones cover several topics and would be better as smaller clusters, and which have no natural main article — with an article worth writing.', 'clusterlane' ); ?></p>
+                </div>
+                <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" id="clusterlane-review-open"><?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Review cluster structure', 'clusterlane' ); ?></button>
+            </div>
+
+            <dialog id="clusterlane-review" class="clusterlane-review-dialog" aria-labelledby="clusterlane-review-title">
+                <div class="clusterlane-review-head">
+                    <div>
+                        <h2 id="clusterlane-review-title"><?php esc_html_e( 'Cluster structure', 'clusterlane' ); ?></h2>
+                        <p><?php esc_html_e( 'Pillar and split check for every cluster. Nothing changes here — open a cluster to act on its suggestion.', 'clusterlane' ); ?></p>
+                    </div>
+                    <button type="button" class="clusterlane-review-close" data-review-close aria-label="<?php esc_attr_e( 'Close', 'clusterlane' ); ?>">×</button>
+                </div>
+                <div class="clusterlane-review-body" id="clusterlane-review-body" aria-live="polite"></div>
+            </dialog>
+
+            <!-- ============ Automatic clusters ============ -->
+            <h2 class="clusterlane-section-heading"><?php esc_html_e( 'Automatic clusters', 'clusterlane' ); ?> <span class="clusterlane-section-sub"><?php esc_html_e( 'from your categories and tags', 'clusterlane' ); ?></span></h2>
+
+            <form method="post" action="" id="clusterlane-ca-form">
+                <?php wp_nonce_field( 'clusterlane_cluster_settings_save' ); ?>
+
+                <?php // The threshold filters the lists below, so it stands above them (0.98.185; it sat under the tags). ?>
+                <p class="clusterlane-ca-mincount">
+                    <label><?php esc_html_e( 'Only list categories, tags and pages with at least', 'clusterlane' ); ?>
+                        <input type="number" name="cluster_min_count" min="2" max="100" value="<?php echo (int) $min_count; ?>">
+                        <?php esc_html_e( 'articles.', 'clusterlane' ); ?></label>
+                    <span class="clusterlane-tm-note"><?php esc_html_e( 'Smaller terms rarely make useful clusters. For pages as clusters: the pages pointing to the page (at least 2).', 'clusterlane' ); ?></span>
+                </p>
+
+                <div class="clusterlane-settings-grid">
+                    <div class="clusterlane-panel-card">
+                        <div class="clusterlane-ca-card-head">
+                            <h3><?php clusterlane_glyph_e( 'folder' ); ?><?php esc_html_e( 'Categories', 'clusterlane' ); ?></h3>
+                            <?php if ( $categories ): ?><span class="clusterlane-ca-bulk"><button type="button" class="clusterlane-tm-linkbtn" data-bulk="cats" data-on="1"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></button> · <button type="button" class="clusterlane-tm-linkbtn" data-bulk="cats" data-on="0"><?php esc_html_e( 'Clear', 'clusterlane' ); ?></button></span><?php endif; ?>
+                        </div>
+                        <p class="description"><?php esc_html_e( 'Tick the categories that should be clusters.', 'clusterlane' ); ?></p>
+                        <?php if ( empty( $categories ) ): ?>
+                        <p class="clusterlane-ls2-empty"><?php printf( /* translators: %d: number */ esc_html__( 'No categories with at least %d posts were found.', 'clusterlane' ), (int) $min_count ); ?></p>
+                        <?php else: ?>
+                        <div class="clusterlane-checkbox-list">
+                            <?php foreach ( $categories as $term ): $sel = in_array( (int) $term->term_id, $selected_categories, true ); ?>
+                            <label class="clusterlane-ca-term">
+                                <input type="checkbox" name="cluster_categories[]" value="<?php echo esc_attr( $term->term_id ); ?>" <?php checked( $sel ); ?>>
+                                <span class="clusterlane-ca-term-name"><?php echo esc_html( $term->name ); ?></span>
+                                <?php echo wp_kses_post( $status( 'category_' . $term->term_id, $sel ) ); ?>
+                                <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d article', '%d articles', (int) $term->count, 'clusterlane' ), (int) $term->count ) ); ?></span>
+                                <?php if ( isset( $unscanned[ (int) $term->term_id ] ) ): ?>
+                                <span class="clusterlane-ca-unscanned"><?php clusterlane_glyph_e( 'alert' ); ?><?php
+                                    /* translators: %s: post type names, e.g. "Docs, Guides" */
+                                    printf( esc_html__( 'None of these articles is scanned (%s) — add the post type under Settings → Content.', 'clusterlane' ), esc_html( $unscanned[ (int) $term->term_id ] ) );
+                                ?></span>
+                                <?php endif; ?>
+                            </label>
+                            <?php endforeach; ?>
+                        </div>
+                        <?php endif; ?>
+                    </div>
+
+                    <div class="clusterlane-panel-card">
+                        <div class="clusterlane-ca-card-head">
+                            <h3><?php clusterlane_glyph_e( 'tag' ); ?><?php esc_html_e( 'Tags', 'clusterlane' ); ?></h3>
+                            <?php if ( $tags ): ?><span class="clusterlane-ca-bulk"><button type="button" class="clusterlane-tm-linkbtn" data-bulk="tags" data-on="1"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></button> · <button type="button" class="clusterlane-tm-linkbtn" data-bulk="tags" data-on="0"><?php esc_html_e( 'Clear', 'clusterlane' ); ?></button></span><?php endif; ?>
+                        </div>
+                        <p class="description"><?php printf( /* translators: %d: number */ esc_html__( 'Tags used by %d or more articles, best candidates first: tags that run across many categories make the most useful clusters.', 'clusterlane' ), (int) $min_count ); ?></p>
+                        <?php if ( empty( $tags ) ): ?>
+                        <p class="clusterlane-ls2-empty"><?php printf( /* translators: %d: number */ esc_html__( 'No tags with at least %d posts were found.', 'clusterlane' ), (int) $min_count ); ?></p>
+                        <?php else: ?>
+                        <div class="clusterlane-checkbox-list">
+                            <?php foreach ( $tags as $term ):
+                                $sel = in_array( (int) $term->term_id, $selected_tags, true );
+                                $ov  = $overlap[ $term->term_id ] ?? null;
+                                $fit = '';
+                                if ( $ov && $ov['dominant_category'] ) {
+                                    $pct = (int) $ov['overlap_pct']; $cat = $ov['dominant_category']->name;
+                                    if ( $pct >= 80 )     $fit = '<span class="clusterlane-ca-fit clusterlane-ca-fit--high">' . esc_html( sprintf( /* translators: %1$s: text, %2$d: number */ __( 'Mostly overlaps “%1$s” (%2$d%%)', 'clusterlane' ), $cat, $pct ) ) . '</span>';
+                                    elseif ( $pct >= 50 ) $fit = '<span class="clusterlane-ca-fit clusterlane-ca-fit--mid">' . esc_html( sprintf( /* translators: %1$s: text, %2$d: number */ __( 'Partly overlaps “%1$s” (%2$d%%)', 'clusterlane' ), $cat, $pct ) ) . '</span>';
+                                    else                  $fit = '<span class="clusterlane-ca-fit clusterlane-ca-fit--low">' . esc_html( sprintf( /* translators: %d: number */ _n( 'Spans %d category', 'Spans %d categories', (int) $ov['spans'], 'clusterlane' ), (int) $ov['spans'] ) ) . '</span>';
+                                }
+                            ?>
+                            <label class="clusterlane-ca-term">
+                                <input type="checkbox" name="cluster_tags[]" value="<?php echo esc_attr( $term->term_id ); ?>" <?php checked( $sel ); ?>>
+                                <span class="clusterlane-ca-term-name"><?php echo esc_html( $term->name ); ?><?php echo wp_kses_post( $fit ); ?></span>
+                                <?php echo wp_kses_post( $status( 'post_tag_' . $term->term_id, $sel ) ); ?>
+                                <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d article', '%d articles', (int) $term->count, 'clusterlane' ), (int) $term->count ) ); ?></span>
+                                <?php if ( isset( $unscanned[ (int) $term->term_id ] ) ): ?>
+                                <span class="clusterlane-ca-unscanned"><?php clusterlane_glyph_e( 'alert' ); ?><?php
+                                    /* translators: %s: post type names, e.g. "Docs, Guides" */
+                                    printf( esc_html__( 'None of these articles is scanned (%s) — add the post type under Settings → Content.', 'clusterlane' ), esc_html( $unscanned[ (int) $term->term_id ] ) );
+                                ?></span>
+                                <?php endif; ?>
+                            </label>
+                            <?php endforeach; ?>
+                        </div>
+                        <?php endif; ?>
+                    </div>
+
+                    <?php if ( $shop ) : ?>
+                    <div class="clusterlane-panel-card">
+                        <div class="clusterlane-ca-card-head">
+                            <h3><?php clusterlane_glyph_e( 'bag' ); ?><?php esc_html_e( 'Product categories', 'clusterlane' ); ?></h3>
+                            <?php if ( $pcats ): ?><span class="clusterlane-ca-bulk"><button type="button" class="clusterlane-tm-linkbtn" data-bulk="pcats" data-on="1"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></button> · <button type="button" class="clusterlane-tm-linkbtn" data-bulk="pcats" data-on="0"><?php esc_html_e( 'Clear', 'clusterlane' ); ?></button></span><?php endif; ?>
+                        </div>
+                        <p class="description"><?php esc_html_e( 'WooCommerce: tick the product categories that should be clusters. Products are then scanned and scored as product pages, and Text Match finds articles that mention a product without linking to it. Products hidden from the shop are left out.', 'clusterlane' ); ?></p>
+                        <?php if ( empty( $pcats ) ): ?>
+                        <p class="clusterlane-ls2-empty"><?php printf( /* translators: %d: number */ esc_html__( 'No product categories with at least %d products were found.', 'clusterlane' ), (int) $min_count ); ?></p>
+                        <?php else: ?>
+                        <div class="clusterlane-checkbox-list">
+                            <?php foreach ( $pcats as $term ): $sel = in_array( (int) $term->term_id, $selected_pcats, true ); ?>
+                            <label class="clusterlane-ca-term">
+                                <input type="checkbox" name="cluster_product_cats[]" value="<?php echo esc_attr( $term->term_id ); ?>" <?php checked( $sel ); ?>>
+                                <span class="clusterlane-ca-term-name"><?php echo esc_html( $term->parent ? html_entity_decode( get_term( (int) $term->parent, 'product_cat' )->name ?? '', ENT_QUOTES ) . ' › ' . $term->name : $term->name ); ?></span>
+                                <?php echo wp_kses_post( $status( 'product_cat_' . $term->term_id, $sel ) ); ?>
+                                <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d product', '%d products', (int) $term->count, 'clusterlane' ), (int) $term->count ) ); ?></span>
+                            </label>
+                            <?php endforeach; ?>
+                        </div>
+                        <?php endif; ?>
+                    </div>
+                    <?php endif; ?>
+
+                    <?php
+                    // Pages as clusters (0.98.180): post types other content points to through custom fields.
+                    \Clusterlane\Core\HubClusters::relations( true ); // look again: fields may have been added
+                    $hub_cands = \Clusterlane\Core\HubClusters::candidates();
+                    $hub_on    = \Clusterlane\Core\HubClusters::enabled();
+                    if ( $hub_cands ):
+                        $pt_label = function ( string $pt ): string { $o = get_post_type_object( $pt ); return $o ? html_entity_decode( (string) $o->labels->name, ENT_QUOTES, 'UTF-8' ) : $pt; };
+                    ?>
+                    <div class="clusterlane-panel-card clusterlane-ca-hubs">
+                        <input type="hidden" name="cluster_hubs_shown" value="1">
+                        <div class="clusterlane-ca-card-head">
+                            <h3><?php clusterlane_glyph_e( 'link' ); ?><?php esc_html_e( 'Pages as clusters', 'clusterlane' ); ?></h3>
+                        </div>
+                        <p class="description"><?php esc_html_e( 'Your content points to these pages through custom fields. Tick a kind of page to make each page a cluster — with the pages that point to it, and the pages that point to those. New content joins by itself; the page itself is the pillar unless you choose another.', 'clusterlane' ); ?></p>
+                        <div class="clusterlane-checkbox-list">
+                            <?php foreach ( $hub_cands as $hub_type => $cand ): $sel = in_array( $hub_type, $hub_on, true ); ?>
+                            <label class="clusterlane-ca-term clusterlane-ca-hub">
+                                <input type="checkbox" name="cluster_hubs[]" value="<?php echo esc_attr( $hub_type ); ?>" <?php checked( $sel ); ?>>
+                                <span class="clusterlane-ca-term-name"><?php
+                                    /* translators: %s: post type, e.g. "Islands" */
+                                    echo esc_html( sprintf( __( '%s — each page a cluster', 'clusterlane' ), $cand['label'] ) );
+                                ?>
+                                <span class="clusterlane-ca-hub-paths"><?php
+                                    $parts = [];
+                                    foreach ( $cand['paths'] as $path ) {
+                                        $parts[] = isset( $path['via'] )
+                                            /* translators: 1: post type, e.g. "Attractions", 2: field name, 3: post type, e.g. "Areas" */
+                                            ? sprintf( __( '%1$s through their %3$s (%2$s)', 'clusterlane' ), $pt_label( $path['from'] ), $path['key'], mb_strtolower( $pt_label( $path['via'] ) ) )
+                                            /* translators: 1: post type, e.g. "Areas", 2: field name, e.g. "area_island" */
+                                            : sprintf( __( '%1$s (%2$s)', 'clusterlane' ), $pt_label( $path['from'] ), $path['key'] );
+                                    }
+                                    /* translators: %s: list like "Areas (area_island), Attractions through their areas (place_area)" */
+                                    echo esc_html( sprintf( __( 'with %s', 'clusterlane' ), implode( ', ', $parts ) ) );
+                                ?></span></span>
+                                <?php if ( $sel ) echo '<span class="clusterlane-ca-status clusterlane-ca-status--on">' . esc_html__( 'Clusters', 'clusterlane' ) . '</span>'; ?>
+                                <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d page', '%d pages', $cand['pages'], 'clusterlane' ), $cand['pages'] ) ); ?></span>
+                            </label>
+                            <?php
+                            $hub_rows = $sel ? array_filter( $by_key, fn( $c ) => $c->cluster_type === \Clusterlane\Core\HubClusters::PREFIX . $hub_type && (int) $c->is_active ) : [];
+                            if ( $hub_rows ): usort( $hub_rows, fn( $x, $y ) => strcasecmp( $x->cluster_label, $y->cluster_label ) ); ?>
+                            <div class="clusterlane-ca-hub-list">
+                                <?php foreach ( $hub_rows as $c ): ?>
+                                <div class="clusterlane-ca-term clusterlane-ca-hub-item">
+                                    <span class="clusterlane-ca-term-name"><?php echo esc_html( $c->cluster_label ); ?></span>
+                                    <?php echo wp_kses_post( $status( $c->cluster_key, true ) ); ?>
+                                    <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d article', '%d articles', (int) $c->total_members, 'clusterlane' ), (int) $c->total_members ) ); ?></span>
+                                </div>
+                                <?php endforeach; ?>
+                            </div>
+                            <?php endif; ?>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                    <?php endif; ?>
+
+                    <?php foreach ( $extra_terms as $tax_name => $list ): $tax_obj = $extra_taxes[ $tax_name ]; $sel_ids = $selected_extra[ $tax_name ] ?? []; ?>
+                    <div class="clusterlane-panel-card">
+                        <input type="hidden" name="cluster_terms_shown[]" value="<?php echo esc_attr( $tax_name ); ?>">
+                        <div class="clusterlane-ca-card-head">
+                            <h3><?php clusterlane_glyph_e( $tax_obj->hierarchical ? 'folder' : 'tag' ); ?><?php echo esc_html( html_entity_decode( (string) $tax_obj->labels->name, ENT_QUOTES, 'UTF-8' ) ); ?></h3>
+                            <span class="clusterlane-ca-bulk"><button type="button" class="clusterlane-tm-linkbtn" data-bulk-name="cluster_terms[<?php echo esc_attr( $tax_name ); ?>][]" data-on="1"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></button> · <button type="button" class="clusterlane-tm-linkbtn" data-bulk-name="cluster_terms[<?php echo esc_attr( $tax_name ); ?>][]" data-on="0"><?php esc_html_e( 'Clear', 'clusterlane' ); ?></button></span>
+                        </div>
+                        <p class="description"><?php
+                            $types = array_filter( array_map( function ( $pt ) { $o = get_post_type_object( $pt ); return $o ? $o->labels->name : ''; }, (array) $tax_obj->object_type ) );
+                            /* translators: 1: taxonomy name, e.g. "Regions", 2: post type names, e.g. "Attractions" */
+                            printf( esc_html__( 'Tick the %1$s that should be clusters (used by %2$s).', 'clusterlane' ), esc_html( mb_strtolower( html_entity_decode( (string) $tax_obj->labels->name, ENT_QUOTES, 'UTF-8' ) ) ), esc_html( implode( ', ', $types ) ) );
+                        ?></p>
+                        <div class="clusterlane-checkbox-list">
+                            <?php foreach ( $list as $term ): $sel = in_array( (int) $term->term_id, $sel_ids, true ); ?>
+                            <label class="clusterlane-ca-term">
+                                <input type="checkbox" name="cluster_terms[<?php echo esc_attr( $tax_name ); ?>][]" value="<?php echo esc_attr( (string) $term->term_id ); ?>" <?php checked( $sel ); ?>>
+                                <span class="clusterlane-ca-term-name"><?php echo esc_html( $term->parent ? html_entity_decode( (string) ( get_term( (int) $term->parent, $tax_name )->name ?? '' ), ENT_QUOTES ) . ' › ' . $term->name : $term->name ); ?></span>
+                                <?php echo wp_kses_post( $status( $tax_name . '_' . $term->term_id, $sel ) ); ?>
+                                <span class="clusterlane-checkbox-count"><?php echo esc_html( sprintf( /* translators: %d: number */ _n( '%d article', '%d articles', (int) $term->count, 'clusterlane' ), (int) $term->count ) ); ?></span>
+                                <?php if ( isset( $unscanned[ (int) $term->term_id ] ) ): ?>
+                                <span class="clusterlane-ca-unscanned"><?php clusterlane_glyph_e( 'alert' ); ?><?php
+                                    /* translators: %s: post type names, e.g. "Docs, Guides" */
+                                    printf( esc_html__( 'These articles aren’t scanned yet (%s) — ticking this adds them when you save.', 'clusterlane' ), esc_html( $unscanned[ (int) $term->term_id ] ) );
+                                ?></span>
+                                <?php endif; ?>
+                            </label>
+                            <?php endforeach; ?>
+                        </div>
+                    </div>
+                    <?php endforeach; ?>
+                </div>
+
+
+                <!-- Appears only when the selection has changed -->
+                <div class="clusterlane-ca-savebar" id="clusterlane-ca-savebar" hidden>
+                    <span><strong id="clusterlane-ca-changes"></strong> <span class="clusterlane-tm-note"><?php esc_html_e( 'Clusters are rebuilt when you save — this can take a moment on large sites.', 'clusterlane' ); ?></span></span>
+                    <span class="clusterlane-ca-savebar-actions">
+                        <button type="button" class="clusterlane-tm-btn" id="clusterlane-ca-revert"><?php esc_html_e( 'Undo changes', 'clusterlane' ); ?></button>
+                        <button type="submit" name="clusterlane_save_cluster_settings" class="clusterlane-tm-btn clusterlane-tm-btn--apply"><?php esc_html_e( 'Save & rebuild clusters', 'clusterlane' ); ?></button>
+                    </span>
+                </div>
+            </form>
+
+            <hr class="clusterlane-section-divider">
+
+            <!-- ============ Your own clusters ============ -->
+            <h2 class="clusterlane-section-heading"><?php esc_html_e( 'Your own clusters', 'clusterlane' ); ?> <span class="clusterlane-section-sub"><?php esc_html_e( 'hand-picked articles — changes save instantly', 'clusterlane' ); ?></span></h2>
+
+            <div class="clusterlane-panel-card clusterlane-panel-card--wide" id="clusterlane-ca-custom">
+                <p class="description" style="margin-top:0;"><?php esc_html_e( 'Use a custom cluster for a topic that runs across several categories and has no tag of its own — for example “Christmas in Stockholm” or “Rainy day ideas”.', 'clusterlane' ); ?></p>
+
+                <div class="clusterlane-ca-toolbar">
+                    <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" id="clusterlane-ca-new"><?php clusterlane_glyph_e( 'pencil' ); ?><?php esc_html_e( 'New custom cluster', 'clusterlane' ); ?></button>
+                    <?php
+                    if ( \Clusterlane\Core\Edition::has( 'ai' ) && has_action( 'clusterlane_cluster_admin_ai' ) ):
+                        do_action( 'clusterlane_cluster_admin_ai' );   // "Get ideas with AI" (premium)
+                    else:
+                        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php'; ?>
+                        <span class="clusterlane-ca-pro-note"><?php echo wp_kses_post( \Clusterlane\Admin\ProExamples::note_html( 'ai', __( 'Get cluster ideas with AI', 'clusterlane' ), __( 'AI reads your article titles and proposes cross-cutting clusters you can review and create.', 'clusterlane' ) ) ); ?></span>
+                    <?php endif; ?>
+                </div>
+
+                <div id="clusterlane-ca-ideas"></div>
+                <div id="clusterlane-ca-new-slot"></div>
+
+                <div class="clusterlane-browse__table-wrap" id="clusterlane-ca-table-wrap"<?php echo $custom_clusters ? '' : ' hidden'; ?>>
+                    <table class="clusterlane-browse__table clusterlane-ca-table">
+                        <thead><tr>
+                            <th class="clusterlane-browse__col-title"><?php esc_html_e( 'Cluster', 'clusterlane' ); ?></th>
+                            <th class="num"><?php esc_html_e( 'Articles', 'clusterlane' ); ?></th>
+                            <th class="num"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?><?php clusterlane_tip( 'orphans' ); ?></th>
+                            <th class="num"><?php esc_html_e( 'Health', 'clusterlane' ); ?><?php clusterlane_tip( 'health' ); ?></th>
+                            <th class="clusterlane-ca-actions-col"></th>
+                        </tr></thead>
+                        <tbody id="clusterlane-ca-rows">
+                        <?php foreach ( $custom_clusters as $c ): $h = (int) $c->health_score; $o = $orphans_by_key[ $c->cluster_key ] ?? 0; ?>
+                            <tr class="clusterlane-ca-row" data-key="<?php echo esc_attr( $c->cluster_key ); ?>" data-label="<?php echo esc_attr( $c->cluster_label ); ?>">
+                                <td class="clusterlane-browse__col-title"><strong class="clusterlane-ca-name"><?php echo esc_html( $c->cluster_label ); ?></strong></td>
+                                <td class="num clusterlane-ca-members"><?php echo (int) $c->total_members; ?></td>
+                                <td class="num clusterlane-ca-orphans<?php echo $o ? ' clusterlane-browse__zero' : ' clusterlane-browse__muted'; ?>"><?php echo (int) $o; ?></td>
+                                <td class="num clusterlane-ca-health"><span class="clusterlane-ci-health-cell clusterlane-ci-detail-health--<?php echo esc_attr( $band( $h ) ); ?>"><span class="clusterlane-ci-health-node" aria-hidden="true"></span><?php echo (int) $h; ?>%</span></td>
+                                <td class="clusterlane-ca-actions">
+                                    <button type="button" class="clusterlane-tm-btn clusterlane-ca-edit"><?php esc_html_e( 'Edit', 'clusterlane' ); ?></button>
+                                    <a class="clusterlane-tm-btn" href="<?php echo esc_url( admin_url( 'admin.php?page=clusterlane-cluster-inspector&open=' . rawurlencode( $c->cluster_key ) ) ); ?>"><?php esc_html_e( 'Inspect', 'clusterlane' ); ?></a>
+                                    <button type="button" class="clusterlane-tm-btn clusterlane-ca-delete" aria-label="<?php echo esc_attr( sprintf( /* translators: %s: text */ __( 'Delete %s', 'clusterlane' ), $c->cluster_label ) ); ?>"><?php clusterlane_glyph_e( 'trash' ); ?></button>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                        </tbody>
+                    </table>
+                </div>
+                <p class="clusterlane-ls2-empty" id="clusterlane-ca-empty"<?php echo $custom_clusters ? ' hidden' : ''; ?>><?php esc_html_e( 'No custom clusters yet. Create one, or let AI suggest some.', 'clusterlane' ); ?></p>
+            </div>
+        </div>
+
+
+
+        <?php
+    }
+
+    private function save( \Clusterlane\Core\Settings $settings, ?\Clusterlane\Core\ClusterEngine $engine ): void {
+        $cats = array_map( 'intval', (array) ( $_POST['cluster_categories'] ?? [] ) );  // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called
+        $tags = array_map( 'intval', (array) ( $_POST['cluster_tags'] ?? [] ) );  // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called
+
+        $settings->set( 'enabled_categories', array_values( array_unique( array_filter( $cats ) ) ) );
+        // WooCommerce: product categories, and products among the scanned post types when any is picked.
+        if ( \Clusterlane\Core\Shop::active() ) {
+            $pcats = array_values( array_unique( array_filter( array_map( 'intval', (array) ( $_POST['cluster_product_cats'] ?? [] ) ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called
+            $settings->set( 'enabled_product_cats', $pcats );
+            if ( $pcats && ! in_array( \Clusterlane\Core\Shop::TYPE, $settings->post_types(), true ) ) {
+                $settings->set( 'post_types', array_values( array_unique( array_merge( $settings->post_types(), [ \Clusterlane\Core\Shop::TYPE ] ) ) ) );
+            }
+        }
+        $settings->set( 'enabled_tags', array_values( array_unique( array_filter( $tags ) ) ) );
+        // Keep Scan Settings → Taxonomies in step with what was picked here.
+        if ( $tags && ! in_array( 'post_tag', $settings->taxonomies(), true ) ) {
+            $settings->set( 'taxonomies', array_values( array_unique( array_merge( $settings->taxonomies(), [ 'post_tag' ] ) ) ) );
+        }
+        // Other taxonomies (0.98.177): only those whose card was on the page, so an
+        // empty card means "none of these" and a taxonomy not shown keeps its selection.
+        $extra = $settings->enabled_terms();
+        $posted = [];
+        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified by check_admin_referer(); taxonomy => term IDs, sanitized in the loop
+        foreach ( (array) wp_unslash( $_POST['cluster_terms'] ?? [] ) as $tax => $ids ) {
+            $posted[ sanitize_key( (string) $tax ) ] = array_map( 'absint', (array) $ids );
+        }
+        foreach ( array_map( 'sanitize_key', (array) ( $_POST['cluster_terms_shown'] ?? [] ) ) as $tax ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer()
+            if ( ! taxonomy_exists( $tax ) || in_array( $tax, \Clusterlane\Core\ClusterTypes::BUILT_IN, true ) ) continue;
+            $ids = array_values( array_unique( array_filter( array_map( 'intval', (array) ( $posted[ $tax ] ?? [] ) ), fn( $id ) => $id > 0 && term_exists( $id, $tax ) ) ) );
+            $extra[ $tax ] = $ids;
+            // Articles of a taxonomy picked here are scanned (as with WooCommerce products).
+            if ( $ids ) $this->scan_types_of( $settings, $tax );
+        }
+        $settings->set( 'enabled_terms', $extra );
+        // Pages as clusters (0.98.180).
+        if ( isset( $_POST['cluster_hubs_shown'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer()
+            $cands = array_keys( \Clusterlane\Core\HubClusters::candidates() );
+            $hubs  = array_values( array_intersect( array_map( 'sanitize_key', (array) ( $_POST['cluster_hubs'] ?? [] ) ), $cands ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer()
+            $settings->set( 'enabled_hubs', $hubs );
+        }
+        $settings->set( 'cluster_terms_configured', true );
+        if ( isset( $_POST['cluster_min_count'] ) ) {  // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called
+            $settings->set( 'min_term_count', min( 100, max( 2, (int) $_POST['cluster_min_count'] ) ) );  // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() before save() is called
+        }
+
+        // Rebuild immediately so the new selection is fully reflected —
+        // health/archetype/etc. — rather than showing zeroed-out
+        // clusters until the next manual scan.
+        if ( $engine ) {
+            $engine->sync_all();
+            $engine->compute_all();
+        }
+    }
+
+    /** Every post type with its own pages (as Settings → Content lists them). @return string[] */
+    private function public_post_types(): array {
+        $types = array_keys( get_post_types( [ 'public' => true ] ) + get_post_types( [ 'publicly_queryable' => true ] ) );
+        return array_values( array_diff( $types, [ 'attachment' ] ) );
+    }
+
+    /** Add a taxonomy's public post types to the scanned ones, unless one already is. */
+    private function scan_types_of( \Clusterlane\Core\Settings $settings, string $tax ): void {
+        $obj = get_taxonomy( $tax );
+        if ( ! $obj ) return;
+        $public  = $this->public_post_types();
+        $types   = array_values( array_intersect( (array) $obj->object_type, $public ) );
+        $scanned = $settings->post_types();
+        if ( ! $types || array_intersect( $types, $scanned ) ) return;
+        $settings->set( 'post_types', array_values( array_unique( array_merge( $scanned, $types ) ) ) );
+    }
+
+    /** WooCommerce product categories with at least $min_count products, by name. */
+    private function get_clusterable_product_cats( int $min_count = 2 ): array {
+        $terms = get_terms( [ 'taxonomy' => \Clusterlane\Core\Shop::TAXONOMY, 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] );
+        if ( is_wp_error( $terms ) || empty( $terms ) ) return [];
+        return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count && $t->slug !== 'uncategorized' ) );
+    }
+
+    private function get_clusterable_categories( int $min_count = 2 ): array {
+        $terms = get_categories( [ 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] );
+        if ( is_wp_error( $terms ) || empty( $terms ) ) return [];
+        return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) );
+    }
+
+    /** Tags with at least $min_count published posts, alphabetical (re-sorted by overlap later). */
+    private function get_clusterable_tags( int $min_count = 2 ): array {
+        $terms = get_terms( [ 'taxonomy' => 'post_tag', 'hide_empty' => true, 'orderby' => 'name', 'order' => 'ASC' ] );
+        if ( is_wp_error( $terms ) || empty( $terms ) ) return [];
+        return array_values( array_filter( $terms, fn( $t ) => (int) ( $t->count ?? 0 ) >= $min_count ) );
+    }
+
+    /**
+     * For each clusterable tag, how much its posts overlap with a
+     * single category — the signal for deciding which tags are worth
+     * turning into clusters. A tag whose posts are nearly all in ONE
+     * category is largely redundant as a cluster; one spread across
+     * many categories ("cross-cutting") usually makes a more useful
+     * cluster, since it surfaces connections category browsing alone
+     * wouldn't reveal.
+     *
+     * @return array<int, array{overlap_pct: int, dominant_category: \WP_Term|null}>
+     */
+    /**
+     * Terms whose published articles are all of post types Clusterlane doesn't
+     * scan (e.g. a Docs category while only Posts are scanned) — they would
+     * become empty clusters. @return array<int,string> term_id → post type labels
+     */
+    private function unscanned_terms( array $terms ): array {
+        global $wpdb;
+        $ids = array_map( fn( $t ) => (int) $t->term_taxonomy_id, $terms );
+        if ( ! $ids ) return [];
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            "SELECT tt.term_id, p.post_type, COUNT(*) AS n
+             FROM {$wpdb->term_relationships} tr
+             INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
+             INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND p.post_status = 'publish'
+             WHERE tr.term_taxonomy_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")
+             GROUP BY tt.term_id, p.post_type",
+            $ids
+        ) );
+        $scanned = \Clusterlane\Core\Scope::post_types();
+        $by      = [];
+        foreach ( (array) $rows as $r ) $by[ (int) $r->term_id ][ $r->post_type ] = (int) $r->n;
+        $out = [];
+        foreach ( $by as $tid => $types ) {
+            if ( array_intersect( array_keys( $types ), $scanned ) ) continue; // at least some are scanned
+            $labels = array_map( function ( $pt ) { $o = get_post_type_object( $pt ); return $o ? $o->labels->name : $pt; }, array_keys( $types ) );
+            $out[ $tid ] = implode( ', ', $labels );
+        }
+        return $out;
+    }
+
+    private function get_tag_category_overlap( array $tags ): array {
+        // Two queries for all tags (0.98.171) — was one query per tag plus a category
+        // lookup per article, thousands of queries with hundreds of tags.
+        global $wpdb;
+        $result = [];
+        $ids    = array_values( array_filter( array_map( fn( $t ) => (int) $t->term_id, $tags ) ) );
+        if ( ! $ids ) return $result;
+        // Scope::sql() has no values; Scope::type_sql() is prepared; the tag IDs are placeholders.
+        $from   = "{$wpdb->term_relationships} tr
+                   INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'post_tag' AND tt.term_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")
+                   INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND " . \Clusterlane\Core\Scope::sql( 'p' ) . ' AND ' . \Clusterlane\Core\Scope::type_sql( 'p' );
+        $totals = [];
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT tt.term_id AS tag, COUNT(DISTINCT p.ID) AS n FROM ' . $from . ' GROUP BY tt.term_id', $ids ) ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+            $totals[ (int) $r->tag ] = (int) $r->n;
+        }
+        $counts = [];
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+            'SELECT tt.term_id AS tag, ct.term_id AS cat, COUNT(DISTINCT p.ID) AS n FROM ' . $from . "
+             INNER JOIN {$wpdb->term_relationships} cr ON cr.object_id = p.ID
+             INNER JOIN {$wpdb->term_taxonomy} ct ON ct.term_taxonomy_id = cr.term_taxonomy_id AND ct.taxonomy = 'category'
+             GROUP BY tt.term_id, ct.term_id ORDER BY tt.term_id, n DESC, ct.term_id ASC",
+            $ids
+        ) ) as $r ) {
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
+            $counts[ (int) $r->tag ][ (int) $r->cat ] = (int) $r->n;
+        }
+        foreach ( $tags as $tag ) {
+            $tid   = (int) $tag->term_id;
+            $total = $totals[ $tid ] ?? 0;
+            if ( $total === 0 ) continue;
+            $category_counts   = $counts[ $tid ] ?? [];
+            $overlap_pct       = 0;
+            $dominant_category = null;
+            if ( $category_counts ) {
+                $max_count       = max( $category_counts );
+                $dominant_cat_id = array_search( $max_count, $category_counts, true ); // most articles; ties → lowest term ID
+                $overlap_pct     = (int) round( ( $max_count / $total ) * 100 );
+                $term = get_term( $dominant_cat_id, 'category' );
+                $dominant_category = ( $term && ! is_wp_error( $term ) ) ? $term : null;
+            }
+            $result[ $tid ] = [
+                'overlap_pct'       => $overlap_pct,
+                'dominant_category' => $dominant_category,
+                'spans'             => count( $category_counts ),
+            ];
+        }
+        return $result;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-help.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-help.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-help.php	(revision 3751027)
@@ -0,0 +1,442 @@
+<?php
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Clusterlane Help
+ *
+ * Shared tooltip helper and help page renderer.
+ * clusterlane_tip( 'entropy' ) outputs a CSS-driven ? popover inline.
+ */
+
+/* ------------------------------------------------------------------
+   Tooltip definitions
+------------------------------------------------------------------ */
+function clusterlane_tip_definitions(): array {
+    // One source for every "?" tooltip (PHP via clusterlane_tip(), JS via clusterlaneTip())
+    // and for the Term reference on the Help page. Keep the numbers in sync
+    // with ClusterEngine / Scanner — they describe the actual formulas.
+    // label = heading · text = what it is · good = what good looks like · fix = what to do.
+    return [
+        'health' => [
+            'label' => __( 'Cluster health', 'clusterlane' ),
+            'text'  => __( 'Overall structural quality of a cluster, 0–100. Weighted from: article scores 30%, share of articles that have inbound links 25%, link density 20%, evenness of inbound links (inverse Gini) 15% and entropy 10%.', 'clusterlane' ),
+            'good'  => __( '70+ is strong, 50–69 needs attention, under 50 is weak. The site’s average health (Dashboard, Cluster Inspector, sections, reports) is weighted by articles: a 40-article cluster counts eight times as much as a 5-article one.', 'clusterlane' ),
+            'fix'   => __( 'Link the orphans first — that moves the two biggest weights at once.', 'clusterlane' ),
+        ],
+        'entropy' => [
+            'label' => __( 'Entropy', 'clusterlane' ),
+            'text'  => __( 'How evenly the articles in a cluster share the work of linking to each other, 0–100. 100 = every article that links contributes equally; low = one or two articles do almost all the linking.', 'clusterlane' ),
+            'good'  => __( 'Above 60 is healthy. Balanced Mesh needs 80+ (default threshold).', 'clusterlane' ),
+            'fix'   => __( 'Add links FROM articles that rarely link to their siblings.', 'clusterlane' ),
+        ],
+        'gini' => [
+            'label' => __( 'Gini', 'clusterlane' ),
+            'text'  => __( 'Inequality of inbound links inside a cluster, 0–100. 0 = every article gets the same number of links; 100 = one article gets them all.', 'clusterlane' ),
+            'good'  => __( 'Under 40 is even. A high Gini is fine for a deliberate pillar structure, but 70+ together with low entropy means one hub hoards the links.', 'clusterlane' ),
+            'fix'   => __( 'Point new links at Light and Orphan articles, not only at the pillar.', 'clusterlane' ),
+        ],
+        'density' => [
+            'label' => __( 'Density', 'clusterlane' ),
+            'text'  => __( 'Share of all possible links between the cluster’s articles that actually exist, in %. 10 articles have 90 possible links; 9 existing links = 10%.', 'clusterlane' ),
+            'good'  => __( 'Under 8% is classed as Weak Structure. Big clusters naturally have lower density than small ones.', 'clusterlane' ),
+            'fix'   => __( 'Use Text Match and Link Suggestions within the cluster.', 'clusterlane' ),
+        ],
+        'archetype' => [
+            'label' => __( 'Archetype', 'clusterlane' ),
+            'text'  => __( 'The cluster’s shape, read from its metrics. Checked in this order: Emerging (fewer than 6 articles), Balanced Mesh, Authority Pyramid, Over-Centralized Hub, Weak Structure, Fragmented (over 25% orphans), Developed Cluster, otherwise Mixed — analysed, but no single pattern stands out. WooCommerce product categories are always Product Range. Unclassified means the cluster has not been computed yet.', 'clusterlane' ),
+            'good'  => __( 'Balanced Mesh, Authority Pyramid and Developed Cluster are healthy shapes.', 'clusterlane' ),
+            'fix'   => __( 'The thresholds can be tuned under Settings → Cluster Thresholds.', 'clusterlane' ),
+        ],
+        'sections' => [
+            'label' => __( 'Sections', 'clusterlane' ),
+            'text'  => __( 'Clusters grouped the way visitors see the site: by the top items of your main menu. A category cluster goes under the menu item its category (or a parent of it) sits under; a category not in the menu goes under its top-level parent category. Tag and custom clusters go where most of their articles belong. The rest are under Other.', 'clusterlane' ),
+            'good'  => __( 'The menu decides before the category tree: a child category with its own top menu item is its own section.', 'clusterlane' ),
+            'fix'   => __( 'Make your own groups with + New group, then drag clusters onto a section header — or open a cluster and pick its Section in the info bar. Automatic puts it back. Your groups come first; rename, reorder or delete them from their header.', 'clusterlane' ),
+        ],
+        'archetype_mix' => [
+            'label' => __( 'Archetype mix', 'clusterlane' ),
+            'text'  => __( 'How your clusters are distributed across archetypes. Hover a segment to see its count.', 'clusterlane' ),
+            'good'  => __( 'A growing share of Balanced Mesh, Authority Pyramid and Developed Cluster means the site structure is improving.', 'clusterlane' ),
+        ],
+        'role' => [
+            'label' => __( 'Role (Pillar · Support · Light · Orphan)', 'clusterlane' ),
+            'text'  => __( 'An article’s role in a cluster, from its inbound links compared with the other members. Pillar = the cluster’s main article: the best-linked one (two in clusters of 20+), or the one you chose with Make pillar in Cluster Inspector. Light = bottom 35% with at most 1 inbound link. Orphan = no inbound links. Support = everything in between.', 'clusterlane' ),
+            'good'  => __( 'One clear pillar that every other article links to, mostly supports, few lights and no orphans.', 'clusterlane' ),
+        ],
+        'orphans' => [
+            'label' => __( 'Orphans', 'clusterlane' ),
+            'text'  => __( 'Articles that no other article on the site links to. Readers and search engines can only reach them through menus, archives or search.', 'clusterlane' ),
+            'good'  => __( '0.', 'clusterlane' ),
+            'fix'   => __( 'Open the cluster in Cluster Inspector → Improve this cluster, or run Text Match.', 'clusterlane' ),
+        ],
+        'pillars' => [
+            'label' => __( 'Pillar articles', 'clusterlane' ),
+            'text'  => __( 'The main article of each cluster: the best-linked article, if it is in the cluster’s top quarter by inbound links and has at least 2 — one per cluster, two in clusters of 20 or more. If you choose a pillar yourself (the medal button in Cluster Inspector), that article is the only one.', 'clusterlane' ),
+            'good'  => __( 'Every cluster with 4+ articles should have one — ideally an overview or guide that links to the rest.', 'clusterlane' ),
+            'fix'   => __( 'No natural main article? Pillar & split check (Pro) suggests one to write; publish it, then make it the pillar with the medal button.', 'clusterlane' ),
+        ],
+        'seo_titles' => [
+            'label' => __( 'SEO title & meta description', 'clusterlane' ),
+            'text'  => __( 'What an article shows as its search result: the blue title and the grey text under it. Read from your SEO plugin or custom fields (Settings → Content).', 'clusterlane' ),
+            'good'  => __( 'Title up to 60 characters, description 70–160, both specific to the article.', 'clusterlane' ),
+            'fix'   => __( 'Edit the text here and click Apply — or select articles and let AI suggest (Pro). Clusterlane writes only to custom fields; with an SEO plugin, copy the text into the post editor.', 'clusterlane' ),
+        ],
+        'overall_score' => [
+            'label' => __( 'Article score', 'clusterlane' ),
+            'text'  => __( 'An article’s score, 0–100: SEO basics 35% + structure 35% + inbound links 15% + outbound links 15% (10 links in either direction gives full marks for that part). A WooCommerce product: SEO 35% + product page 35% (description, short description, images) + inbound links 30% (5 give full marks) — products are not expected to link out.', 'clusterlane' ),
+            'good'  => __( '80+ is good, 60–79 OK, under 60 weak.', 'clusterlane' ),
+            'fix'   => __( 'Add H2 headings, a meta description, images and internal links.', 'clusterlane' ),
+        ],
+        'avg_score' => [
+            'label' => __( 'Avg score', 'clusterlane' ),
+            'text'  => __( 'Average article score across all scanned articles (SEO basics, structure and internal links).', 'clusterlane' ),
+            'good'  => __( '80+ is good, 60–79 OK, under 60 weak.', 'clusterlane' ),
+        ],
+        'seo_score' => [
+            'label' => __( 'SEO score', 'clusterlane' ),
+            'text'  => __( 'SEO title 20–70 characters (30 pts, 15 if outside) + meta description of 50+ characters (25) + word count 600+ (30, 15 from 300) + featured image (15). Title and description come from your SEO plugin or the fields set in Settings → Content, with the post title and excerpt as fallback.', 'clusterlane' ),
+        ],
+        'structure_score' => [
+            'label' => __( 'Structure score', 'clusterlane' ),
+            'text'  => __( 'Word count 800+ (30 pts, 20 from 400, 10 from 200) + at least one H2 per 200 words (40, 20 if fewer) + 2+ images per 1,000 words (30, 15 from 0.5).', 'clusterlane' ),
+        ],
+        'avg_inbound' => [
+            'label' => __( 'Avg inbound', 'clusterlane' ),
+            'text'  => __( 'Average number of internal links pointing to each scanned article.', 'clusterlane' ),
+            'good'  => __( 'Higher means articles are easier to reach. Compare it over time rather than against a fixed target.', 'clusterlane' ),
+        ],
+        'inbound' => [
+            'label' => __( 'Inbound links', 'clusterlane' ),
+            'text'  => __( 'Links from other articles on this site to this article.', 'clusterlane' ),
+        ],
+        'outbound' => [
+            'label' => __( 'Outbound links', 'clusterlane' ),
+            'text'  => __( 'Internal links from this article to other articles on this site. External links are not counted.', 'clusterlane' ),
+        ],
+        'percentile' => [
+            'label' => __( 'Percentile', 'clusterlane' ),
+            'text'  => __( 'The article’s rank within the cluster by inbound links. p80 = it has more inbound links than 80% of the cluster’s articles.', 'clusterlane' ),
+            'good'  => __( 'Pillars are p75 or higher.', 'clusterlane' ),
+        ],
+        'global_class' => [
+            'label' => __( 'Global content class', 'clusterlane' ),
+            'text'  => __( 'The article’s role across the whole site, independent of clusters: Orphan = 0 inbound links, Pillar = 5+ inbound, Support = 3+ outbound, otherwise Light.', 'clusterlane' ),
+        ],
+        'word_count' => [
+            'label' => __( 'Word count', 'clusterlane' ),
+            'text'  => __( 'Words in the article body, without HTML tags and shortcodes.', 'clusterlane' ),
+        ],
+        'image_count' => [
+            'label' => __( 'Images', 'clusterlane' ),
+            'text'  => __( 'Images found in the article: <img> tags plus common gallery shortcodes (native [gallery], Envira, NextGEN and any you add in Settings).', 'clusterlane' ),
+        ],
+        'h2_count' => [
+            'label' => __( 'H2 headings', 'clusterlane' ),
+            'text'  => __( 'Number of H2 headings. The structure score expects at least one per 200 words.', 'clusterlane' ),
+        ],
+        'site_map' => [
+            'label' => __( 'Site map', 'clusterlane' ),
+            'text'  => __( 'Each rectangle is a cluster. Its area is the number of articles in it; its colour is the cluster\'s health, or the share of articles no other article links to. An article can belong to several clusters.', 'clusterlane' ),
+            'good'  => __( 'Large red or dark blue areas are where most of your content needs work — start there.', 'clusterlane' ),
+        ],
+        'improve_article' => [
+            'label' => __( 'Improve this article', 'clusterlane' ),
+            'text'  => __( 'What to do with this article, most important first: links it lacks (none or few pointing here, no link to its cluster\'s pillar), mentions that can be linked in one click, meta description and structure. Calculated from your scan and a Text Match check, no AI.', 'clusterlane' ),
+            'good'  => __( 'Work from the top. Links show up in the graph after the next Scan.', 'clusterlane' ),
+        ],
+        'focus_next' => [
+            'label' => __( 'Focus next', 'clusterlane' ),
+            'text'  => __( 'The three clusters below 70% health where work pays off most: low health weighted by size, plus articles with no links pointing to them. Calculated from your scan, no AI.', 'clusterlane' ),
+            'good'  => __( 'Open a cluster and start with Improve this cluster; the list updates after the next scan and after every change you make in Clusterlane.', 'clusterlane' ),
+        ],
+        'graph_layout' => [
+            'label' => __( 'How the graph is laid out', 'clusterlane' ),
+            'text'  => __( 'Each link pulls two articles together like a spring, and every article pushes the others away. Well-linked articles settle in the middle; articles with few links in the cluster drift to the edge. Size = inbound links, colour = role.', 'clusterlane' ),
+            'good'  => __( 'Distance to the middle means something: the further out, the weaker an article is linked into the cluster. Two articles next to each other at the edge are not related — they are both weakly linked and happened to land there.', 'clusterlane' ),
+            'fix'   => __( 'Give the articles at the edge a few links from their siblings (Improve this cluster, Text Match). The picture stays the same between visits until the links change.', 'clusterlane' ),
+        ],
+        'flow_map' => [
+            'label' => __( 'Cluster flow map', 'clusterlane' ),
+            'text'  => __( 'Each node is a cluster (size = number of articles, colour = health). Each line is internal links between two clusters (width = number of links).', 'clusterlane' ),
+            'good'  => __( 'Related clusters should be connected; an isolated weak cluster is a good place to start.', 'clusterlane' ),
+        ],
+    ];
+}
+
+/* ------------------------------------------------------------------
+   Tooltip helper — call clusterlane_tip('entropy') anywhere in admin HTML.
+   Renders the "?" trigger only; admin/js/clusterlane-tips.js shows the popover
+   (positioned to stay on screen, keyboard and touch friendly).
+   JS-rendered UI uses the same definitions via clusterlaneTip( 'entropy' ).
+------------------------------------------------------------------ */
+function clusterlane_tip( string $key ): void {
+    echo wp_kses( clusterlane_tip_html( $key ), [
+        'span' => [ 'class' => true, 'data-clusterlane-tip' => true, 'tabindex' => true, 'role' => true, 'aria-label' => true, 'aria-hidden' => true ],
+    ] );
+}
+
+function clusterlane_tip_html( string $key ): string {
+    $defs = clusterlane_tip_definitions();
+    if ( ! isset( $defs[ $key ] ) ) return '';
+    /* translators: %s: term, e.g. Entropy */
+    $label = sprintf( __( 'What is %s?', 'clusterlane' ), $defs[ $key ]['label'] );
+    return '<span class="clusterlane-tip" data-clusterlane-tip="' . esc_attr( $key ) . '" tabindex="0" role="button" aria-label="' . esc_attr( $label ) . '">'
+         . '<span class="clusterlane-tip-icon" aria-hidden="true">?</span></span>';
+}
+
+/* ------------------------------------------------------------------
+   Help page renderer
+------------------------------------------------------------------ */
+function clusterlane_render_help_page( ?callable $admin_section = null ): void {
+    $help_url = admin_url( 'admin.php?page=clusterlane-help' );
+    ?>
+    <div class="wrap clusterlane-wrap">
+        <?php clusterlane_render_sticky_bar( 'help' ); ?>
+
+        <!-- Getting started -->
+        <div class="clusterlane-page-hero">
+            <?php clusterlane_icon_e( 'help' ); ?>
+            <div>
+                <h1><?php esc_html_e( 'Help & Getting Started', 'clusterlane' ); ?></h1>
+                <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'Six steps from scan to better linked content — and answers to common questions.', 'clusterlane' ); ?></span>
+            </div>
+        </div>
+
+        <div class="clusterlane-panel-card" style="padding:28px 32px;">
+            <div class="clusterlane-help-steps">
+                <?php
+                $u = fn( string $page ) => admin_url( 'admin.php?page=' . $page );
+                $steps = [
+                    [
+                        'title' => __( 'Scan your site', 'clusterlane' ),
+                        'text'  => [
+                            __( 'Click Scan in the bar at the top of any Clusterlane page. Clusterlane reads every published article — words, headings, images, SEO title and description, scores and internal links — turns the categories, tags and other groups you picked in Cluster Admin into clusters, and works out each cluster’s health, archetype and the role of every article in it.', 'clusterlane' ),
+                            __( 'Run Scan again after publishing or editing articles, or after changing their categories or tags. Changes you make in Clusterlane itself — links, pillars, clusters — update the figures at once. The article texts take well under a minute for a few hundred articles. If Clusterlane also reads your published pages (Settings → Content), those are read afterwards in the background — Pages 120/296 next to Scan shows how far it has come, and you can keep working.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-dashboard', __( 'Go to Dashboard', 'clusterlane' ), true ],
+                    ],
+                    [
+                        'title' => __( 'Get the overview on the Dashboard', 'clusterlane' ),
+                        'text'  => [
+                            __( 'The Site map shows every cluster at once — size is the number of articles, colour is health (or the share of articles without links). Click a cluster to see its link graph below.', 'clusterlane' ),
+                            __( 'Site Intelligence Analysis (Pro) reads the whole structure and writes what works, what to fix and in which order. Each action has a button that opens the right tool for the right cluster.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-dashboard', __( 'Open Dashboard', 'clusterlane' ), false ],
+                    ],
+                    [
+                        'title' => __( 'Fix clusters in Cluster Inspector', 'clusterlane' ),
+                        'text'  => [
+                            __( 'Start with Focus next — the clusters where a few links make the biggest difference. Open a cluster and use Improve this cluster: link articles that nothing links to, turn existing mentions into links, and find missing articles.', 'clusterlane' ),
+                            __( 'Each cluster has one pillar (two in clusters of 20+ articles): its main article. Clusterlane picks the best-linked one; use the medal button in the article table to choose it yourself. Pillar & split check (Pro) tells you whether the pillar fits and whether a broad cluster should become smaller ones.', 'clusterlane' ),
+                            __( 'A long list is grouped into sections like your site’s main menu. Make your own groups with + New group and drag clusters onto them.', 'clusterlane' ),
+                            __( 'The eye next to a cluster’s name (here and in Report) opens a quick view: health and its trend, links, orphans, pillar, meta descriptions and the articles to start with. ← and → step through the other clusters in the list.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-cluster-inspector', __( 'Open Cluster Inspector', 'clusterlane' ), false ],
+                    ],
+                    [
+                        'title' => __( 'Improve single articles in Article Inspector', 'clusterlane' ),
+                        'text'  => [
+                            __( 'Open any article: the row under the title shows its scores and links at a glance, and Improve this article lists what to do, most important first — with the fixes one click away. The search preview shows the SEO title and description as Google shows them; with Pro, Suggest writes better ones.', 'clusterlane' ),
+                            __( 'Articles to review lists every article with the ones that need attention first; filter by cluster, role or missing meta description.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-article-inspector', __( 'Open Article Inspector', 'clusterlane' ), false ],
+                    ],
+                    [
+                        'title' => __( 'Check every search result on SEO Titles', 'clusterlane' ),
+                        'text'  => [
+                            __( 'SEO Titles lists every article’s SEO title and meta description as they show in Google. Filter on No description, Weak description, Long title or Duplicates, edit by hand and apply — each change can be undone for 30 minutes, and the first time Clusterlane changes an article’s title or description it keeps the original, so Restore original brings it back at any time.', 'clusterlane' ),
+                            __( 'With Pro, Check with AI reads each article and flags titles and descriptions that do not match it (AI: poor match), and Suggest with AI writes new ones. You choose per field: use the suggestion, or keep what you have.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-seo-titles', __( 'Open SEO Titles', 'clusterlane' ), false ],
+                    ],
+                    [
+                        'title' => __( 'Add links — you approve every one', 'clusterlane' ),
+                        'text'  => [
+                            __( 'Text Match finds places where an article already mentions another article’s title without linking to it. Link Suggestions (Pro) writes a natural sentence with the link for any two articles. Every change needs your Apply, and can be undone.', 'clusterlane' ),
+                            __( 'Looked through the list and it all fits? Apply all links every mention on it, one after another, with a Stop button. Mentions that can no longer be linked (the words changed or are already linked) are skipped; any that fail for a passing reason stay on the list with the reason. Undo run removes every link the run added.', 'clusterlane' ),
+                            __( 'New links and link cards go into the article’s own text — never inside a box, a gallery, a page-builder module or an older link card. If the right place is unclear, the card goes at the end and the preview tells you so.', 'clusterlane' ),
+                        ],
+                        'link'  => [ 'clusterlane-text-match', __( 'Open Text Match', 'clusterlane' ), false ],
+                    ],
+                ];
+                foreach ( $steps as $i => $st ) : ?>
+                <div class="clusterlane-help-step">
+                    <div class="clusterlane-help-step-num"><?php echo (int) $i + 1; ?></div>
+                    <div class="clusterlane-help-step-body">
+                        <h3><?php echo esc_html( $st['title'] ); ?></h3>
+                        <?php foreach ( $st['text'] as $k => $para ) : ?>
+                            <p<?php echo $k ? ' style="margin-top:6px;"' : ''; ?>><?php echo esc_html( $para ); ?></p>
+                        <?php endforeach; ?>
+                        <a href="<?php echo esc_url( $u( $st['link'][0] ) ); ?>" class="clusterlane-action-btn <?php echo esc_attr( $st['link'][2] ? 'clusterlane-action-btn--primary' : 'clusterlane-action-btn--secondary' ); ?>" style="margin-top:12px;display:inline-flex;">→ <?php echo esc_html( $st['link'][1] ); ?></a>
+                    </div>
+                </div>
+                <?php endforeach; ?>
+
+            </div>
+        </div>
+
+        <!-- Understanding Clusters FAQ -->
+        <details class="clusterlane-panel-card clusterlane-help-reference" style="margin-top:16px;">
+            <summary class="clusterlane-help-reference-summary">
+                <span style="font-size:15px;"><?php clusterlane_glyph_e( 'nodes' ); ?></span>
+                <strong><?php esc_html_e( 'Understanding clusters', 'clusterlane' ); ?></strong>
+                <span style="color:#94a3b8;font-size:12px;margin-left:8px;"><?php esc_html_e( 'What they are, why they matter, and how to think about them', 'clusterlane' ); ?></span>
+            </summary>
+            <div class="clusterlane-help-reference-body">
+
+                <?php
+                $faqs = [
+                    [ __( 'What is a content cluster?', 'clusterlane' ),
+                      __( 'A group of articles that cover the same broad topic from different angles. One article is the pillar — the main, most complete piece — and the others go deeper on parts of the topic and link to it and to each other. Together they form a tightly connected neighbourhood of content.', 'clusterlane' ) ],
+                    [ __( 'Why do clusters matter for SEO?', 'clusterlane' ),
+                      __( 'Search engines judge how thoroughly a site covers a subject, not only single pages. A well-linked cluster shows that your site is an in-depth source on a topic, and the links let search engines — and readers — find every article and understand how they relate.', 'clusterlane' ) ],
+                    [ __( 'Where do clusters come from?', 'clusterlane' ),
+                      __( 'From your categories and tags, from other taxonomies of your content (regions, types, genres …), from pages your content points to through custom fields (Pages as clusters, below), and from custom clusters you put together yourself for topics that cut across categories — for example “Stockholm for families” — or create from a split suggestion. Choose them in Cluster Admin. An article can be in several clusters.', 'clusterlane' ) ],
+                    [ __( 'My site is built from custom post types that point to each other. Can those be clusters?', 'clusterlane' ),
+                      __( 'Yes — with Pages as clusters in Cluster Admin. If your areas name their region in a field, or your hotels their destination (ACF post object or relationship fields, or any field holding a post ID), Clusterlane finds those fields by itself and offers the pages they point to. Tick a kind of page and each page becomes a cluster with the pages pointing to it, and the pages pointing to those. The page itself is the pillar, new content joins by itself, and a page needs at least two pages pointing to it (the Cluster Admin minimum) to be a cluster.', 'clusterlane' ) ],
+                    [ __( 'My theme links pages through lists, cards or “related” sections. Does Clusterlane see those links?', 'clusterlane' ),
+                      __( 'Only when it reads the published page. Tick the post type under Settings → Content → Read the published pages: Clusterlane then reads each page’s main content as visitors see it (menus, breadcrumbs, sidebars and the footer are left out) and counts the links there too. Post types with almost no links in their text are marked Recommended, and the Dashboard warns about them. If a page can’t be read — a firewall, a security plugin, Cloudflare — the scan says so and the article keeps its text links. Use cached pages makes it faster when your page cache is cleared on publishing.', 'clusterlane' ) ],
+                    [ __( 'Why are articles placed where they are in the graph?', 'clusterlane' ),
+                      __( 'Each link pulls two articles together like a spring, and every article pushes the others away. Well-linked articles settle in the middle; articles with few links in the cluster drift to the edge. So distance to the middle means something — two articles next to each other at the edge are not related, they are both weakly linked. The picture is the same on every visit until the links change.', 'clusterlane' ) ],
+                    [ __( 'What is a pillar, and can I choose it?', 'clusterlane' ),
+                      __( 'The pillar is a cluster’s main article — the one every other article should link to. Clusterlane gives each cluster one pillar (two in clusters of 20 or more articles): the best-linked article. You can choose it yourself with the medal button in Cluster Inspector’s article table; then that article is the only pillar, and everything else — Improve this article, the graphs and the AI analyses — uses your choice. Click the medal again to let Clusterlane decide.', 'clusterlane' ) ],
+                    [ __( 'What do Support, Light and Orphan mean?', 'clusterlane' ),
+                      __( 'Supports are the cluster’s ordinary, reasonably linked articles. Light articles get at most one link from other articles. Orphans get none — readers and search engines can only reach them through menus and search, so they are the first thing to fix.', 'clusterlane' ) ],
+                    [ __( 'What does the health score measure?', 'clusterlane' ),
+                      __( 'Health (0–100) combines five things: the articles’ own quality scores (30%), how few orphans there are (25%), how much the articles link to each other (20%), how evenly the links are spread (15%) and topic breadth (10%). Above 70 is strong; under 50 needs attention. The site’s average on the Dashboard and in Cluster Inspector is weighted by articles, so big clusters count for more.', 'clusterlane' ) ],
+                    [ __( 'What do the archetypes mean — and what is “Mixed”?', 'clusterlane' ),
+                      __( 'The archetype describes the shape of a cluster’s links: Balanced Mesh, Authority Pyramid and Developed Cluster are healthy shapes; Weak Structure, Fragmented and Over-Centralized Hub point to a problem; Emerging means the cluster is still small. Mixed means the cluster has been analysed but no single pattern stands out — typically medium linking without a clear main article. Choosing a pillar and linking the other articles to it is a good first step.', 'clusterlane' ) ],
+                    [ __( 'My cluster has a low health score. What should I do first?', 'clusterlane' ),
+                      __( 'Open it in Cluster Inspector and follow Improve this cluster from the top: link the orphans, turn unlinked mentions into links (one click each), make sure every article links to the pillar, and then look at missing content. On the Dashboard, Site Intelligence Analysis ranks the clusters and actions for the whole site.', 'clusterlane' ) ],
+                    [ __( 'My cluster has no good pillar. What then?', 'clusterlane' ),
+                      __( 'When every article covers a single place or item, no article can act as the main page. Pillar & split check (Pro) says so and suggests an overview article worth writing. Write it as a guide that introduces the topic and links to every article in the cluster, publish it, and make it the pillar with the medal button. Then run Text Match or Link Suggestions so the other articles link back to it.', 'clusterlane' ) ],
+                    [ __( 'When should I split a cluster?', 'clusterlane' ),
+                      __( 'When its articles fall into topics readers look for separately. Pillar & split check (Pro) suggests 2–4 smaller clusters, always divided one way only — by area, by style or by period, never a mix — and shows a second way as a switch when that also works. Groups of only 3–5 articles are often better left together under one pillar with a section per group; you decide, and nothing is created until you click Create. Review cluster structure in Cluster Admin runs the check for every cluster.', 'clusterlane' ) ],
+                    [ __( 'How do I keep a long cluster list manageable?', 'clusterlane' ),
+                      __( 'Cluster Inspector groups the list into sections: the top items of your main menu, or your top-level categories if there is no menu. Tag and custom clusters go where most of their articles belong, and pages as clusters under the page at the top of their hierarchy (an island with its areas). Make your own groups with + New group, drag clusters onto any section header, rename and reorder your groups, and switch Group to None for one flat list.', 'clusterlane' ) ],
+                    [ __( 'How many clusters should I have?', 'clusterlane' ),
+                      __( 'One per distinct topic you want to be found for. Clusters of 2–3 articles are usually better merged; a cluster of 50+ articles often covers several topics that would do better as separate clusters, each with its own pillar.', 'clusterlane' ) ],
+                    [ __( 'Where does Clusterlane read the SEO title and meta description?', 'clusterlane' ),
+                      __( 'From your SEO plugin (Yoast, Rank Math, SEOPress, All in One SEO, The SEO Framework), or from your own fields or ACF fields — set this under Settings → Content. Clusterlane only writes AI suggestions back to your own fields, never into an SEO plugin’s storage; there the suggestions are copy-only.', 'clusterlane' ) ],
+                    [ __( 'Does my permalink structure matter?', 'clusterlane' ),
+                      __( 'No. Clusters come from the categories, tags and fields your articles have, not from their URLs, and links are matched to articles the way WordPress itself does — flat URLs, category paths, .htm endings, dates or ?p=123 all work. A link written with http instead of https, with or without www, still counts, and so does a link to an article’s old slug that WordPress redirects. Run Scan after changing the permalink structure.', 'clusterlane' ) ],
+                    [ __( 'Does Clusterlane work on a multilingual site?', 'clusterlane' ),
+                      __( 'Yes, with Polylang or WPML. Text Match and Link Suggestions only suggest links between articles in the same language, AI writes link sentences, SEO titles and descriptions in each article’s own language, and Article Inspector and SEO Titles get a language filter (and a Language column in their CSV exports). Clusters follow your categories, which these plugins already keep per language.', 'clusterlane' ) ],
+                    [ __( 'Does Clusterlane work with WooCommerce?', 'clusterlane' ),
+                      __( 'Yes. Tick product categories under Cluster Admin → Product categories: they become clusters and products are scanned as product pages. A product’s score looks at its description (150+ words), short description, images and SEO — not article rules like 800 words and subheadings — and at articles linking to it. Text Match finds articles that mention a product by its full name without linking to it. Products hidden from the shop and variations are left out.', 'clusterlane' ) ],
+                    [ __( 'Can I get the data into a spreadsheet?', 'clusterlane' ),
+                      __( 'Yes. Export CSV on Article Inspector’s list, SEO Titles and Cluster Inspector downloads what the list shows — with the search and filters you have set — as a file Excel, Numbers and Google Sheets open directly. Your whole Clusterlane setup is also in your WordPress database, so a normal database backup covers it.', 'clusterlane' ) ],
+                    [ __( 'Does Clusterlane ever change my content automatically?', 'clusterlane' ),
+                      __( 'No. The editor always has the final word. Links, SEO fields and pillars change only when you click Apply, Make pillar or similar — and links and SEO fields can be undone for 30 minutes. For SEO fields Clusterlane also keeps the original from before its first change, and Restore original brings it back later. Link cards are only ever added to the article’s own text, never inside other boxes. Gap Analysis gives you a brief to write from, never a finished article.', 'clusterlane' ) ],
+                ];
+                $faqs = array_map( fn( $f ) => [ 'q' => $f[0], 'a' => $f[1] ], $faqs );
+                foreach ( $faqs as $faq ): ?>
+                <div class="clusterlane-help-term" style="margin-bottom:18px;">
+                    <dt style="font-weight:600;color:#1e293b;margin-bottom:4px;"><?php echo esc_html( $faq['q'] ); ?></dt>
+                    <dd style="color:#475569;line-height:1.6;"><?php echo esc_html( $faq['a'] ); ?></dd>
+                </div>
+                <?php endforeach; ?>
+
+            </div>
+        </details>
+
+        <!-- AI in Clusterlane -->
+        <details class="clusterlane-panel-card clusterlane-help-reference" style="margin-top:16px;">
+            <summary class="clusterlane-help-reference-summary">
+                <span style="font-size:15px;"><?php clusterlane_glyph_e( 'sparkle' ); ?></span>
+                <strong><?php esc_html_e( 'AI in Clusterlane', 'clusterlane' ); ?></strong>
+                <?php if ( ! \Clusterlane\Core\Edition::has( 'ai' ) ) : ?><span class="clusterlane-pro-badge" style="margin-left:8px;"><?php esc_html_e( 'PRO', 'clusterlane' ); ?></span><?php endif; ?>
+                <span style="color:#94a3b8;font-size:12px;margin-left:8px;"><?php esc_html_e( 'What it does, which model, what it costs and what is sent', 'clusterlane' ); ?></span>
+            </summary>
+            <div class="clusterlane-help-reference-body">
+                <?php
+                $ai = [
+                    [ __( 'What does the AI do?', 'clusterlane' ),
+                      __( 'It analyses your site, clusters and articles (Site Intelligence Analysis, cluster and article analyses), finds missing articles and writes briefs (Gap Analysis), writes link sentences (Link Suggestions), ranks link candidates, suggests new clusters, checks each cluster’s pillar and whether it should be split (Pillar & split check, Review cluster structure), reviews SEO titles and meta descriptions against the article (Check with AI) and writes better ones. Everything else in Clusterlane — scans, health, roles, Text Match, Improve lists — works without AI.', 'clusterlane' ) ],
+                    [ __( 'Which model should I use?', 'clusterlane' ),
+                      __( 'Settings → AI has two choices on the same API key. The everyday model handles the many short calls (link suggestions, ranking, SEO checks and suggestions) — a fast, economical model such as gpt-4o-mini or Claude Haiku is usually best. The analysis model handles the few, longer analyses, where a stronger model such as Claude Opus shows most.', 'clusterlane' ) ],
+                    [ __( 'Editorial or technical tone?', 'clusterlane' ),
+                      __( 'Settings → AI → Tone of analyses decides who the analyses are written for: Editorial uses plain language and editorial actions; Technical uses SEO terms and metrics for specialists and agencies. Changing it gives fresh analyses the next time you run them.', 'clusterlane' ) ],
+                    [ __( 'What does it cost?', 'clusterlane' ),
+                      __( 'You use your own API key and pay the provider directly — typically fractions of a cent per call with an economical model. Settings → AI shows calls, tokens and an estimated cost per feature this month, and admins can let roles run AI or not and set a monthly limit for editors.', 'clusterlane' ) ],
+                    [ __( 'Are results saved?', 'clusterlane' ),
+                      __( 'Yes. Every AI result is saved and shown again for free until the underlying data or settings change. Settings → Advanced → AI cache shows what is saved and lets you clear it — for example after switching model.', 'clusterlane' ) ],
+                    [ __( 'What is sent to the AI provider?', 'clusterlane' ),
+                      __( 'Only what the task needs: article titles, the start of the article text and headings, cluster names and their numbers. Nothing is sent until someone clicks a button — automatic loading only ever shows saved results. Nothing the AI writes reaches your site without your Apply.', 'clusterlane' ) ],
+                ];
+                foreach ( $ai as $f ) : ?>
+                <div class="clusterlane-help-term" style="margin-bottom:18px;">
+                    <dt style="font-weight:600;color:#1e293b;margin-bottom:4px;"><?php echo esc_html( $f[0] ); ?></dt>
+                    <dd style="color:#475569;line-height:1.6;"><?php echo esc_html( $f[1] ); ?></dd>
+                </div>
+                <?php endforeach; ?>
+            </div>
+        </details>
+
+        <!-- Reference -->
+        <details class="clusterlane-panel-card clusterlane-help-reference" style="margin-top:16px;">
+            <summary class="clusterlane-help-reference-summary">
+                <span style="font-size:15px;"><?php clusterlane_glyph_e( 'book' ); ?></span>
+                <strong><?php esc_html_e( 'Term reference', 'clusterlane' ); ?></strong>
+                <span style="color:#94a3b8;font-size:12px;margin-left:8px;"><?php esc_html_e( 'What every score and label means', 'clusterlane' ); ?></span>
+            </summary>
+            <div class="clusterlane-help-reference-body">
+                <?php foreach ( clusterlane_tip_definitions() as $def ): ?>
+                <div class="clusterlane-help-term">
+                    <dt><?php echo esc_html( $def['label'] ); ?></dt>
+                    <dd>
+                        <?php echo esc_html( $def['text'] ); ?>
+                        <?php if ( ! empty( $def['good'] ) ): ?><br><span class="clusterlane-help-term-good"><strong><?php esc_html_e( 'Good:', 'clusterlane' ); ?></strong> <?php echo esc_html( $def['good'] ); ?></span><?php endif; ?>
+                        <?php if ( ! empty( $def['fix'] ) ): ?><br><span class="clusterlane-help-term-fix"><strong><?php esc_html_e( 'Improve:', 'clusterlane' ); ?></strong> <?php echo esc_html( $def['fix'] ); ?></span><?php endif; ?>
+                    </dd>
+                </div>
+                <?php endforeach; ?>
+
+                <h4 style="margin:24px 0 12px;font-size:13px;color:#1e293b;"><?php esc_html_e( 'Article roles (content class)', 'clusterlane' ); ?></h4>
+                <?php
+                $roles = [
+                    'Pillar'  => [ '#f97316', __( 'The cluster’s main article: the best-linked one (two in clusters of 20+ articles), or the one you chose with the medal button. Ideally an overview that links to the rest.', 'clusterlane' ) ],
+                    'Support' => [ '#eab308', __( 'An ordinary, reasonably linked article that covers one part of the topic. Should link to the pillar and to related articles.', 'clusterlane' ) ],
+                    'Light'   => [ '#22c55e', __( 'In the bottom 35% of the cluster by inbound links, with at most one link pointing to it. Easy to reach from nowhere — add a few links to it.', 'clusterlane' ) ],
+                    'Orphan'  => [ '#3b82f6', __( 'No article on the site links to it. Readers and search engines only find it through menus, archives or search — fix these first.', 'clusterlane' ) ],
+                ];
+                foreach ( $roles as $label => [ $color, $desc ] ): ?>
+                <div class="clusterlane-help-term">
+                    <dt>
+                        <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:<?php echo esc_attr( $color ); ?>;vertical-align:middle;margin-right:5px;"></span>
+                        <?php echo esc_html( $label ); ?>
+                    </dt>
+                    <dd><?php echo esc_html( $desc ); ?></dd>
+                </div>
+                <?php endforeach; ?>
+
+                <h4 style="margin:24px 0 12px;font-size:13px;color:#1e293b;"><?php esc_html_e( 'Archetypes', 'clusterlane' ); ?></h4>
+                <?php
+                $archetypes = [
+                    'Authority Pyramid'    => __( 'Healthy cluster with a clear pillar and well-linked support articles. The ideal structure.', 'clusterlane' ),
+                    'Balanced Mesh'        => __( 'Good internal linking with no single dominant article. Works well for topic clusters without a natural hub.', 'clusterlane' ),
+                    'Developed Cluster'    => __( 'Mature cluster with solid coverage and decent linking. Room to strengthen the pillar.', 'clusterlane' ),
+                    'Over-Centralized Hub' => __( 'One article receives almost all inbound links. Other members are underlinked and underexposed.', 'clusterlane' ),
+                    'Fragmented'           => __( 'Poor internal linking throughout. Articles exist in the cluster but don\'t link to each other.', 'clusterlane' ),
+                    'Weak Structure'       => __( 'Low health across the board. Typically a new or neglected cluster with thin content and few links.', 'clusterlane' ),
+                    'Product Range'        => __( 'A WooCommerce product category. Health here is half the product pages’ own scores (description, short description, images, SEO) and half the share of products that articles link to — products are not expected to link to each other.', 'clusterlane' ),
+                    'Emerging Cluster'     => __( 'Fewer than 6 articles — too small to have a clear shape yet. Grow it, or merge it into a related cluster.', 'clusterlane' ),
+                    'Mixed'                => __( 'Analysed, but no single pattern stands out — typically medium linking without a clear main article. Choosing a pillar and linking the other articles to it is a good first step.', 'clusterlane' ),
+                ];
+                foreach ( $archetypes as $label => $desc ): ?>
+                <div class="clusterlane-help-term">
+                    <dt><?php echo esc_html( $label ); ?></dt>
+                    <dd><?php echo esc_html( $desc ); ?></dd>
+                </div>
+                <?php endforeach; ?>
+            </div>
+        </details>
+
+        <?php
+        // Admin-only support section (SupportPanel checks manage_clusterlane itself).
+        if ( $admin_section ) $admin_section();
+        ?>
+
+    </div>
+    <?php
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-inline.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-inline.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-inline.php	(revision 3751027)
@@ -0,0 +1,53 @@
+<?php
+namespace Clusterlane\Admin;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Inline — small page scripts through the WordPress script API (1.3.0).
+ *
+ * Some admin screens carry a few lines of script that belong with the markup
+ * they act on (and use translated strings or URLs from PHP). Instead of
+ * printing <script> tags, the markup is captured between start() and
+ * script(), and the code is attached with wp_add_inline_script() to a
+ * registered footer handle — WordPress prints it after the page, where every
+ * element it looks for already exists.
+ *
+ *     Inline::start(); ?>
+ *     ( function () { … } )();
+ *     <?php Inline::script();
+ *
+ * here() is for the one script that has to run before the browser paints the
+ * element above it (the tabs in the top bar): it prints the code in place
+ * with wp_print_inline_script_tag().
+ */
+final class Inline {
+
+    const HANDLE = 'clusterlane-inline';
+
+    /** Start capturing script code. */
+    public static function start(): void {
+        ob_start();
+    }
+
+    /** Attach the captured code to the footer handle. */
+    public static function script(): void {
+        $js = self::captured();
+        if ( '' === $js ) return;
+        if ( ! wp_script_is( self::HANDLE, 'registered' ) ) {
+            wp_register_script( self::HANDLE, false, [], CLUSTERLANE_VERSION, true );
+        }
+        wp_enqueue_script( self::HANDLE );
+        wp_add_inline_script( self::HANDLE, $js );
+    }
+
+    /** Print the captured code right here (runs before the next paint). */
+    public static function here(): void {
+        $js = self::captured();
+        if ( '' !== $js ) wp_print_inline_script_tag( $js );
+    }
+
+    private static function captured(): string {
+        return trim( (string) ob_get_clean() );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-pro-examples.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-pro-examples.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-pro-examples.php	(revision 3751027)
@@ -0,0 +1,391 @@
+<?php
+namespace Clusterlane\Admin;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * ProExamples
+ *
+ * Static example output shown to Free users where an AI feature would be.
+ * It is rendered by the SAME JS renderers as real AI responses, so it looks
+ * exactly like what Pro produces — but it is clearly labelled as an example
+ * for a fictional demo site ("Lisbon Travel Guide") and never pretends to
+ * be an analysis of the user's own site.
+ *
+ * Shapes match the REST responses: site-analysis, gap/{key},
+ * cluster-inspector/ai-analysis, inspector/ai-analysis, links/suggest.
+ */
+final class ProExamples {
+
+    public static function data(): array {
+        return [
+            'site' => __( 'Lisbon Travel Guide (demo site)', 'clusterlane' ),
+
+            // Site Intelligence Analysis — same shape as the live response (0.98.110+):
+            // "Label: text" strengths, and a tool + clusters per opportunity/action
+            // (shown as disabled "go" buttons; the demo clusters don't exist here).
+            'site_analysis' => [
+                'overview'   => __( 'The site is in fair shape: its 11 clusters score between 38% and 84%, and 9 of 142 articles have no links pointing to them. The neighbourhood guides are the strongest part — they link to each other a lot. Day Trips and Food & Drink are the weakest: few links between their articles, and restaurant pages that mention neighbourhoods without linking to them.', 'clusterlane' ),
+                'strengths'  => [
+                    __( 'Neighbourhood guides reinforce each other: Alfama, Baixa and Belém link to each other in both directions and no article is left out, so the whole topic is easy to find and follow.', 'clusterlane' ),
+                    __( 'Museums has one clear main article: every museum page links to the Lisbon museums guide, which gives the topic one strong page to rank.', 'clusterlane' ),
+                    __( 'Almost no unlinked articles: 9 of 11 clusters have every article linked from at least one other article.', 'clusterlane' ),
+                ],
+                'weak_clusters' => [
+                    [ 'label' => __( 'Day Trips', 'clusterlane' ),    'health' => 38 ],
+                    [ 'label' => __( 'Food & Drink', 'clusterlane' ), 'health' => 47 ],
+                ],
+                'opportunities' => [
+                    [ 'title' => __( 'Day Trips: link the articles to each other', 'clusterlane' ), 'description' => __( 'The 8 day-trip articles link almost only to “Sintra in a Day”. Links between Cascais, Évora and Óbidos would help readers compare and keep them on the site.', 'clusterlane' ),
+                      'tool' => 'improve', 'targets' => [ [ 'key' => 'demo_day_trips', 'label' => __( 'Day Trips', 'clusterlane' ) ] ] ],
+                    [ 'title' => __( 'Food & Drink mentions neighbourhoods without linking', 'clusterlane' ), 'description' => __( 'Six restaurant articles name Alfama or Bairro Alto without a link to those guides — each is a one-click fix.', 'clusterlane' ),
+                      'tool' => 'text_match', 'targets' => [] ],
+                    [ 'title' => __( 'Food & Drink has no clear main article', 'clusterlane' ), 'description' => __( 'Links are spread evenly across 21 articles. Choosing “Where to eat in Lisbon” as the main article and linking every food page to it would give the topic one page to rank.', 'clusterlane' ),
+                      'tool' => 'pillar', 'targets' => [ [ 'key' => 'demo_food', 'label' => __( 'Food & Drink', 'clusterlane' ) ] ] ],
+                    [ 'title' => __( 'Day Trips is missing the inland towns', 'clusterlane' ), 'description' => __( 'Évora and Tomar are common searches from Lisbon but have no articles yet.', 'clusterlane' ),
+                      'tool' => 'gap', 'targets' => [ [ 'key' => 'demo_day_trips', 'label' => __( 'Day Trips', 'clusterlane' ) ] ] ],
+                ],
+                'priority_actions' => [
+                    [ 'action' => __( 'Link the 9 articles that nothing links to', 'clusterlane' ), 'rationale' => __( 'The fastest gain: every article becomes reachable, and the two weakest clusters move toward the site average.', 'clusterlane' ),
+                      'tool' => 'improve', 'targets' => [ [ 'key' => 'demo_day_trips', 'label' => __( 'Day Trips', 'clusterlane' ) ], [ 'key' => 'demo_food', 'label' => __( 'Food & Drink', 'clusterlane' ) ] ] ],
+                    [ 'action' => __( 'Make “Where to eat in Lisbon” the main article of Food & Drink', 'clusterlane' ), 'rationale' => __( 'One clear page for the topic, fed by the 20 restaurant and market articles.', 'clusterlane' ),
+                      'tool' => 'pillar', 'targets' => [ [ 'key' => 'demo_food', 'label' => __( 'Food & Drink', 'clusterlane' ) ] ] ],
+                    [ 'action' => __( 'Write “Évora Day Trip from Lisbon”', 'clusterlane' ), 'rationale' => __( 'Fills the biggest gap in Day Trips and gives Sintra and Cascais a natural article to link to.', 'clusterlane' ),
+                      'tool' => 'gap', 'targets' => [ [ 'key' => 'demo_day_trips', 'label' => __( 'Day Trips', 'clusterlane' ) ] ] ],
+                ],
+            ],
+
+            'seo_meta' => [
+                'suggestions' => [
+                    [ 'title' => __( 'Sintra in a Day: Palaces, Gardens & Trains from Lisbon', 'clusterlane' ),      'description' => __( 'Plan a day trip to Sintra from Lisbon: which palaces to pick, how to beat the queues, and the train times that make it work without a car.', 'clusterlane' ) ],
+                    [ 'title' => __( 'Sintra Day Trip: Pena Palace, Quinta da Regaleira & More', 'clusterlane' ),    'description' => __( 'Fairy-tale palaces, mossy gardens and Atlantic views: see the best of Sintra in one day, with a route that starts and ends at Rossio station.', 'clusterlane' ) ],
+                    [ 'title' => __( 'How to See Sintra in One Day (Without a Car)', 'clusterlane' ),                'description' => __( 'Train from Lisbon, bus 434 up the hill and a walking route between Pena, the Moorish Castle and Regaleira — with tickets and timings for one day.', 'clusterlane' ) ],
+                ],
+                'cached'     => true,
+                'can_write'  => true,
+                'source'     => __( 'Custom fields', 'clusterlane' ),
+                'source_id'  => 'custom',
+                'write_note' => '',
+            ],
+
+            // SEO Titles (0.98.154): two rows of the demo site with the AI's suggestions under each field.
+            'seo_titles' => [
+                'ctx'   => [ 'source' => __( 'Custom fields', 'clusterlane' ), 'source_id' => 'custom', 'title_max' => 60, 'desc_min' => 70, 'desc_max' => 160, 'suffix' => [ 'mode' => 'none', 'text' => '' ] ],
+                'items' => [
+                    [ 'id' => -1, 'title' => __( 'Sintra Day Trip from Lisbon: The Complete Guide to Palaces, Gardens and Getting There', 'clusterlane' ), 'cluster_label' => __( 'Day Trips', 'clusterlane' ), 'role' => 'pillar', 'url' => 'https://lisbon.example/day-trips/sintra/',
+                      'seo' => [ 'title' => __( 'Sintra Day Trip from Lisbon: The Complete Guide to Palaces, Gardens and Getting There', 'clusterlane' ), 'title_from' => 'post_title', 'description' => '', 'description_from' => '', 'title_status' => 'long', 'description_status' => 'missing' ],
+                      'check' => [ 't' => 2, 'd' => 1, 'ti' => __( 'No SEO title — the long post title is cut off in results.', 'clusterlane' ), 'di' => __( 'No meta description.', 'clusterlane' ), 'stale' => false ],
+                      'ai'  => [
+                          [ 'title' => __( 'Sintra in a Day: Palaces, Gardens & Trains from Lisbon', 'clusterlane' ), 'description' => __( 'Plan a day trip to Sintra from Lisbon: which palaces to pick, how to beat the queues, and the train times that make it work without a car.', 'clusterlane' ) ],
+                          [ 'title' => __( 'How to See Sintra in One Day (Without a Car)', 'clusterlane' ), 'description' => __( 'Train from Lisbon, bus 434 up the hill and a walking route between Pena, the Moorish Castle and Regaleira — with tickets and timings for one day.', 'clusterlane' ) ],
+                      ] ],
+                    [ 'id' => -2, 'title' => __( 'Where to Eat in Alfama', 'clusterlane' ), 'cluster_label' => __( 'Food & Drink', 'clusterlane' ), 'role' => 'support', 'url' => 'https://lisbon.example/food/alfama/',
+                      'seo' => [ 'title' => __( 'Where to Eat in Alfama: 12 Tascas Locals Love', 'clusterlane' ), 'title_from' => 'field', 'description' => __( 'Good food in Alfama.', 'clusterlane' ), 'description_from' => 'field', 'title_status' => 'ok', 'description_status' => 'short' ],
+                      'check' => [ 't' => 5, 'd' => 2, 'ti' => '', 'di' => __( 'Too short and generic — names no tasca, dish or price.', 'clusterlane' ), 'stale' => false ],
+                      'ai'  => [
+                          [ 'title' => __( 'Alfama Restaurants: 12 Tascas and Fado Houses', 'clusterlane' ), 'description' => __( 'Twelve small tascas in Alfama where locals eat — grilled sardines, bacalhau and fado nights — with prices, opening hours and which ones to book.', 'clusterlane' ) ],
+                      ] ],
+                ],
+            ],
+
+            'gap' => [
+                'suggestions' => [
+                    [ 'title' => __( 'Évora Day Trip: Roman Temple, Bone Chapel and Wine', 'clusterlane' ), 'rationale' => __( 'The cluster covers Sintra and Cascais but not the most-searched inland day trip.', 'clusterlane' ) ],
+                    [ 'title' => __( 'Getting Around: Trains from Lisbon to Every Day-Trip Town', 'clusterlane' ), 'rationale' => __( 'A practical hub every day-trip article can link to — fixes the single-hub problem.', 'clusterlane' ) ],
+                    [ 'title' => __( 'Day Trips from Lisbon Without a Car', 'clusterlane' ), 'rationale' => __( 'Matches a common search intent the current articles only touch on.', 'clusterlane' ) ],
+                ],
+            ],
+
+            'cluster_analysis' => [
+                'model'        => 'gpt-4o-mini',
+                'summary'      => __( 'Day Trips has 7 articles and a health of 38%. Almost all internal links point to "Sintra in a Day", two articles are orphans, and the cluster has no practical transport hub.', 'clusterlane' ),
+                'strengths'    => [
+                    __( '"Sintra in a Day" is a strong pillar (p100, 6 inbound links).', 'clusterlane' ),
+                    __( 'Articles are long and well illustrated (avg. 1,450 words).', 'clusterlane' ),
+                ],
+                'content_gaps' => [
+                    __( 'Évora and the Alentejo', 'clusterlane' ),
+                    __( 'Train and bus connections', 'clusterlane' ),
+                ],
+                'improvements' => [
+                    [ 'title' => __( 'Link the two orphans', 'clusterlane' ),           'description' => __( '"Óbidos Walled Town" and "Arrábida Beaches" have no inbound links — add them from the Sintra and Cascais articles.', 'clusterlane' ) ],
+                    [ 'title' => __( 'Spread links beyond the pillar', 'clusterlane' ), 'description' => __( 'Gini is 78. Let supporting articles link to each other, not only to Sintra.', 'clusterlane' ) ],
+                ],
+            ],
+
+            'article_analysis' => [
+                'status_summary'       => __( '"Cascais Beaches Guide" is a solid support article (score 74) with 2 inbound and 1 outbound link. It is well structured but under-connected to the rest of Day Trips.', 'clusterlane' ),
+                'content_improvements' => [
+                    [ 'area' => __( 'Meta description', 'clusterlane' ), 'suggestion' => __( 'Missing — add a 140–160 character summary mentioning the train from Cais do Sodré.', 'clusterlane' ) ],
+                    [ 'area' => __( 'Structure', 'clusterlane' ),        'suggestion' => __( 'Split the 600-word "Getting there" section into train, car and bike with H2s.', 'clusterlane' ) ],
+                ],
+                'sister_articles'      => [
+                    [ 'title' => __( 'Boca do Inferno Cliffs at Sunset', 'clusterlane' ), 'rationale' => __( 'Readers of beach guides often look for the nearby viewpoint; no article covers it yet.', 'clusterlane' ) ],
+                ],
+                'cluster_link_actions' => [
+                    [ 'action' => __( 'Link to "Sintra in a Day"', 'clusterlane' ), 'rationale' => __( 'Many visitors combine Cascais and Sintra; the article mentions Sintra twice without a link.', 'clusterlane' ) ],
+                ],
+            ],
+
+            // Review cluster structure — the overview in Cluster Admin (0.98.141).
+            'structure_review' => [
+                'min'      => 6,
+                'clusters' => [
+                    [ 'key' => 'demo_hotels', 'label' => __( 'Hotels', 'clusterlane' ), 'type' => 'category', 'members' => 21, 'health' => 61, 'eligible' => true, 'status' => 'fresh', 'generated_at' => '',
+                      'pillar' => [ 'id' => 1, 'title' => __( 'Where to Stay in Lisbon: The Complete Hotel Guide', 'clusterlane' ), 'chosen' => true ],
+                      'summary' => [ 'by' => __( 'area', 'clusterlane' ), 'alt_by' => __( 'price', 'clusterlane' ), 'groups' => [ [ 'name' => __( 'Alfama & Castelo hotels', 'clusterlane' ), 'n' => 5, 'idea' => '' ], [ 'name' => __( 'Riverside hotels', 'clusterlane' ), 'n' => 4, 'idea' => '' ], [ 'name' => __( 'Avenida & Bairro Alto hotels', 'clusterlane' ), 'n' => 5, 'idea' => __( 'Where to Stay Around Avenida da Liberdade', 'clusterlane' ) ] ], 'note' => '', 'pillar_idea' => '', 'better_pillar' => null ] ],
+                    [ 'key' => 'demo_food', 'label' => __( 'Food & Drink', 'clusterlane' ), 'type' => 'category', 'members' => 21, 'health' => 47, 'eligible' => true, 'status' => 'fresh', 'generated_at' => '',
+                      'pillar' => [ 'id' => 2, 'title' => __( 'Time Out Market: What to Eat', 'clusterlane' ), 'chosen' => false ],
+                      'summary' => [ 'groups' => [ [ 'name' => __( 'Restaurants', 'clusterlane' ), 'n' => 9, 'idea' => '' ], [ 'name' => __( 'Pastries & cafés', 'clusterlane' ), 'n' => 7, 'idea' => '' ] ], 'note' => '', 'pillar_idea' => __( 'Where to Eat in Lisbon: A Neighbourhood Guide', 'clusterlane' ), 'better_pillar' => null ] ],
+                    [ 'key' => 'demo_day_trips', 'label' => __( 'Day Trips', 'clusterlane' ), 'type' => 'category', 'members' => 8, 'health' => 38, 'eligible' => true, 'status' => 'fresh', 'generated_at' => '',
+                      'pillar' => [ 'id' => 3, 'title' => __( 'Cascais by Train', 'clusterlane' ), 'chosen' => false ],
+                      'summary' => [ 'groups' => [], 'note' => __( 'One topic: day trips from Lisbon.', 'clusterlane' ), 'pillar_idea' => '', 'better_pillar' => [ 'id' => 4, 'title' => __( 'Sintra in a Day', 'clusterlane' ) ] ] ],
+                    [ 'key' => 'demo_alfama', 'label' => __( 'Alfama', 'clusterlane' ), 'type' => 'category', 'members' => 12, 'health' => 84, 'eligible' => true, 'status' => 'fresh', 'generated_at' => '',
+                      'pillar' => [ 'id' => 5, 'title' => __( 'Alfama: A Walking Guide', 'clusterlane' ), 'chosen' => true ],
+                      'summary' => [ 'groups' => [], 'note' => __( 'One coherent neighbourhood guide.', 'clusterlane' ), 'pillar_idea' => '', 'better_pillar' => null ] ],
+                    [ 'key' => 'demo_museums', 'label' => __( 'Museums', 'clusterlane' ), 'type' => 'category', 'members' => 14, 'health' => 72, 'eligible' => true, 'status' => 'none',
+                      'pillar' => [ 'id' => 6, 'title' => __( 'Lisbon Museums Guide', 'clusterlane' ), 'chosen' => false ] ],
+                    [ 'key' => 'demo_viewpoints', 'label' => __( 'Viewpoints', 'clusterlane' ), 'type' => 'post_tag', 'members' => 4, 'health' => 55, 'eligible' => false, 'status' => 'none',
+                      'pillar' => [ 'id' => 0, 'title' => '', 'chosen' => false ] ],
+                ],
+            ],
+
+            // Split this cluster — Hotels in the demo site (0.98.138).
+            'cluster_split' => [
+                'cluster' => [ 'key' => 'demo_hotels', 'label' => __( 'Hotels', 'clusterlane' ) ],
+                'members' => [
+                    [ 'id' => 1, 'title' => __( 'Where to Stay in Lisbon: The Complete Hotel Guide', 'clusterlane' ), 'role' => 'pillar', 'thumb' => '' ],
+                    [ 'id' => 2, 'title' => __( 'Memmo Alfama: Rooftop Pool Over the Old Town', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 3, 'title' => __( 'Solar do Castelo: Sleeping Inside the Castle Walls', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 4, 'title' => __( 'Palácio Belmonte: Lisbon’s Oldest Palace Hotel', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 5, 'title' => __( 'Santiago de Alfama: Boutique Hotel on the Tram 28 Line', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 6, 'title' => __( 'Hotel Convento do Salvador', 'clusterlane' ), 'role' => 'orphan', 'thumb' => '' ],
+                    [ 'id' => 7, 'title' => __( 'Altis Belém: Design Hotel on the River', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 8, 'title' => __( 'Pestana Palace: Gardens Above the Tagus', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 9, 'title' => __( 'The Lumiares: Bairro Alto Apartments with a View', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 10, 'title' => __( 'Corinthia Lisbon: Business Hotel Near the Park', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 11, 'title' => __( 'Hotel Cais do Sodré: Waterfront on a Budget', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 12, 'title' => __( 'Lx Boutique Hotel by the River', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 13, 'title' => __( 'Sheraton Lisboa Hotel & Spa', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 14, 'title' => __( 'Home Lisbon Hostel: Family-Run in Baixa', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 15, 'title' => __( 'Lisb’on Hostel: Garden Hostel in Chiado', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 16, 'title' => __( 'Yes! Lisbon Hostel', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 17, 'title' => __( 'Goodmorning Solo Traveller Hostel', 'clusterlane' ), 'role' => 'orphan', 'thumb' => '' ],
+                    [ 'id' => 18, 'title' => __( 'Selina Secret Garden: Hostel with a Pool', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 19, 'title' => __( 'Four Seasons Ritz Lisbon', 'clusterlane' ), 'role' => 'support', 'thumb' => '' ],
+                    [ 'id' => 20, 'title' => __( 'Tivoli Avenida Liberdade', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                    [ 'id' => 21, 'title' => __( 'Bairro Alto Hotel: Rooftop Bar and Views', 'clusterlane' ), 'role' => 'light', 'thumb' => '' ],
+                ],
+                'links'   => [ [ 1, 2 ], [ 1, 3 ], [ 1, 7 ], [ 1, 14 ], [ 1, 19 ], [ 1, 20 ], [ 2, 3 ], [ 3, 4 ], [ 4, 2 ], [ 5, 3 ], [ 5, 2 ], [ 6, 2 ], [ 7, 8 ], [ 7, 12 ], [ 12, 11 ], [ 8, 7 ], [ 11, 12 ], [ 14, 15 ], [ 16, 14 ], [ 18, 15 ], [ 14, 1 ], [ 19, 20 ], [ 20, 10 ], [ 21, 9 ], [ 13, 10 ], [ 9, 1 ], [ 11, 14 ] ],
+                'groups'  => [
+                    [ 'name' => __( 'Alfama & Castelo hotels', 'clusterlane' ), 'why' => __( 'All in the old town around the castle; readers compare them when choosing where to stay there.', 'clusterlane' ), 'ids' => [ 2, 3, 4, 5, 6 ], 'pillar' => 3 ],
+                    [ 'name' => __( 'Riverside hotels', 'clusterlane' ), 'why' => __( 'On the Tagus, from Belém to Cais do Sodré — chosen for the river and the views.', 'clusterlane' ), 'ids' => [ 7, 8, 11, 12 ], 'pillar' => 7 ],
+                    [ 'name' => __( 'Avenida & Bairro Alto hotels', 'clusterlane' ), 'why' => __( 'Along Avenida da Liberdade and up in Bairro Alto — the smart, central part of town.', 'clusterlane' ), 'ids' => [ 9, 10, 19, 20, 21 ], 'pillar' => 20, 'pillar_idea' => __( 'Where to Stay Around Avenida da Liberdade', 'clusterlane' ) ],
+                ],
+                'by'      => __( 'area', 'clusterlane' ),
+                'alternative' => [
+                    'by'     => __( 'price', 'clusterlane' ),
+                    'groups' => [
+                        [ 'name' => __( 'Luxury hotels', 'clusterlane' ), 'why' => __( 'Palaces and five-star hotels, compared by readers with a bigger budget.', 'clusterlane' ), 'ids' => [ 2, 4, 8, 19, 20 ], 'pillar' => 19 ],
+                        [ 'name' => __( 'Budget hotels & hostels', 'clusterlane' ), 'why' => __( 'Hostels and simple hotels that are searched for together.', 'clusterlane' ), 'ids' => [ 11, 14, 15, 16, 17, 18 ], 'pillar' => 14, 'pillar_idea' => __( 'Cheap Places to Stay in Lisbon: Hostels and Budget Hotels by Area', 'clusterlane' ) ],
+                    ],
+                ],
+                'note'    => __( 'Hotels covers three parts of town that readers choose between. Price would also work as a split — both are offered, never mixed. The hostels fit no area and stay in Hotels.', 'clusterlane' ),
+                'cluster_pillar' => 1,
+                'current_pillar' => [ 'id' => 1, 'title' => __( 'Where to Stay in Lisbon: The Complete Hotel Guide', 'clusterlane' ), 'chosen' => true ],
+                'cached'  => true,
+            ],
+
+            // Report summary — two paragraphs in plain words (0.98.125).
+            'report_summary' => [
+                'paragraphs' => [
+                    __( 'The site\'s structure improved over the last twelve weeks. Average cluster health rose from 60% to 68%, mostly because 41 internal links were added and Hotels and Beaches got a clear main article. Articles with no links pointing to them fell from 21 to 9.', 'clusterlane' ),
+                    __( 'The next step is Day Trips, the weakest cluster at 38%: its articles link almost only to one page. Links between the day trips, and a few from Food & Drink (47%) to the neighbourhood guides, would bring both above 50%.', 'clusterlane' ),
+                ],
+            ],
+
+            'link_suggest' => [
+                'source_title' => __( 'Cascais Beaches Guide', 'clusterlane' ),
+                'target_title' => __( 'Sintra in a Day', 'clusterlane' ),
+                'suggestions'  => [
+                    [ 'anchor_text' => __( 'a day in Sintra', 'clusterlane' ),       'context' => __( 'If you have more time, combine the coast with [LINK] — the palaces are 30 minutes away by bus 403.', 'clusterlane' ), 'position' => 'end' ],
+                    [ 'anchor_text' => __( 'Sintra’s palaces', 'clusterlane' ),      'context' => __( 'From Cabo da Roca the road continues inland to [LINK], an easy add-on to a beach day.', 'clusterlane' ),            'position' => 'middle' ],
+                    [ 'anchor_text' => __( 'our Sintra day-trip guide', 'clusterlane' ), 'context' => __( 'Planning the whole coast? Start with [LINK] and end the day on the beach.', 'clusterlane' ),                       'position' => 'beginning' ],
+                ],
+            ],
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       PHP-rendered pieces (pages/sections rendered server-side)
+    ------------------------------------------------------------------ */
+
+    /**
+     * The Report page for the demo site (Free): same shape as Report::build(),
+     * scaled to the chosen period. 142 articles in 11 clusters, like the
+     * Site Analysis example; the 12-week figures match the summary example.
+     * @return array<string,mixed>
+     */
+    public static function report( int $days ): array {
+        $tz    = wp_timezone();
+        $today = new \DateTimeImmutable( 'today', $tz );
+        $start = $today->modify( '-' . $days . ' days' );
+        // How far back the change goes: 1 = the 12-week change, more for 6 months.
+        $f  = static fn( int $ago ): float => $ago <= 84 ? $ago / 84 : 1 + ( $ago - 84 ) / 168;
+        $fp = $f( $days );
+
+        //            label                               n   now then  archetype             pillar support light orphan
+        $rows = [
+            [ __( 'Day Trips', 'clusterlane' ),             8,  38, 29, 'Weak Structure',      1, 1, 4, 2 ],
+            [ __( 'Food & Drink', 'clusterlane' ),          14, 47, 36, 'Fragmented',          1, 3, 8, 2 ],
+            [ __( 'Nightlife', 'clusterlane' ),             7,  55, 44, 'Emerging Cluster',    1, 2, 3, 1 ],
+            [ __( 'Markets', 'clusterlane' ),               9,  61, 52, 'Mixed',               1, 3, 5, 0 ],
+            [ __( 'Hotels', 'clusterlane' ),                21, 64, 55, 'Balanced Mesh',       2, 7, 11, 1 ],
+            [ __( 'Beaches', 'clusterlane' ),               11, 68, 57, 'Balanced Mesh',       1, 4, 5, 1 ],
+            [ __( 'Viewpoints', 'clusterlane' ),            10, 72, 64, 'Balanced Mesh',       1, 4, 5, 0 ],
+            [ __( 'Belém', 'clusterlane' ),                 12, 76, 68, 'Developed Cluster',   1, 5, 6, 0 ],
+            [ __( 'Baixa', 'clusterlane' ),                 15, 79, 70, 'Balanced Mesh',       1, 6, 7, 1 ],
+            [ __( 'Alfama', 'clusterlane' ),                16, 81, 74, 'Balanced Mesh',       1, 7, 8, 0 ],
+            [ __( 'Museums', 'clusterlane' ),               19, 84, 78, 'Authority Pyramid',   1, 8, 10, 0 ],
+        ];
+        $now = []; $then = []; $arch = [];
+        foreach ( $rows as $i => [ $l, $n, $h, $h0, $a, $p, $su, $li, $o ] ) {
+            $k      = 'demo_' . $i;
+            $now[]  = [ 'k' => $k, 'l' => $l, 't' => 'category', 'n' => $n, 'h' => $h, 'a' => $a,
+                        'r' => [ 'pillar' => $p, 'support' => $su, 'light' => $li, 'orphan' => $o ] ];
+            $then[] = [ 'k' => $k, 'h' => (int) round( $h - ( $h - $h0 ) * $fp ) ];
+            $arch[ $a ] = ( $arch[ $a ] ?? 0 ) + 1;
+        }
+        $health = static fn( int $ago ): int => (int) round( 68 - 8 * $f( $ago ) + sin( $ago / 4 ) * 0.8 );
+        $orph   = static fn( int $ago ): int => max( 0, (int) round( 9 + 12 * $f( $ago ) + cos( $ago / 5 ) * 0.6 ) );
+
+        $series = [];
+        for ( $ago = $days; $ago >= 0; $ago-- ) {
+            $series[] = [ 'snap_date' => $today->modify( '-' . $ago . ' days' )->format( 'Y-m-d' ), 'metrics_version' => 1,
+                          'health' => $ago === 0 ? 68 : $health( $ago ), 'orphans' => $ago === 0 ? 9 : $orph( $ago ) ];
+        }
+
+        $g     = $days / 84;
+        $links = (int) round( 41 * $g );
+        $tm    = (int) round( $links * 0.6 );
+        $bins  = (int) ceil( $days / 7 );
+        $shape = [ 2, 3, 4, 2, 5, 3, 4, 3, 5, 4, 3, 3 ];
+        $w     = []; for ( $i = 0; $i < $bins; $i++ ) $w[] = $shape[ $i % count( $shape ) ];
+        $week  = array_map( static fn( $x ) => (int) floor( $x * $links / array_sum( $w ) ), $w );
+        for ( $i = 0; array_sum( $week ) < $links; $i++ ) $week[ $i % $bins ]++;
+
+        $s  = \Clusterlane\Core\Settings::get_instance();
+        $th = $s ? (array) $s->get( 'thresholds', [] ) : [];
+        $b_health = $health( $days );
+        $b_orph   = $orph( $days );
+
+        return [
+            'site_name' => __( 'Lisbon Travel Guide (demo site)', 'clusterlane' ),
+            'host'      => 'lisbon-guide.example',
+            'days'   => $days,
+            'today'  => $today->format( 'Y-m-d' ),
+            'start'  => $start->format( 'Y-m-d' ),
+            'now'    => [
+                'site' => [
+                    'articles' => 142, 'scanned' => 142, 'clusters' => 11, 'health' => 68, 'orphans' => 9, 'avg_inbound' => 6.3,
+                    'roles' => [ 'pillar' => 12, 'support' => 50, 'light' => 71, 'orphan' => 9 ],
+                    'meta'  => [ 'good' => 101, 'weak' => 23, 'missing' => 18 ],
+                    'archetypes' => $arch,
+                ],
+                'clusters' => $now,
+            ],
+            'base'   => [
+                'date'     => $start->format( 'Y-m-d' ),
+                'site'     => [ 'articles' => 142 - (int) round( 9 * $g ), 'clusters' => $days > 84 ? 10 : 11, 'health' => $b_health, 'orphans' => $b_orph,
+                                'avg_inbound' => round( 6.3 - 1.2 * $fp, 1 ) ],
+                'clusters' => $then,
+            ],
+            'partial'    => false,
+            'series'     => $series,
+            'first'      => $today->modify( '-200 days' )->format( 'Y-m-d' ),
+            'activity'   => [
+                'total'   => [ 'link_added' => $links, 'meta_written' => (int) round( 12 * $g ), 'pillar_chosen' => max( 1, (int) round( 3 * min( 1, $g ) ) ), 'draft_created' => (int) round( 2 * $g ) ],
+                'by_tool' => [ 'link_added' => [ 'text_match' => $tm, 'link_suggest' => $links - $tm ] ],
+                'undone'  => (int) round( 3 * $g ),
+            ],
+            'links_week'  => $week,
+            'orphan_list' => array_map( static fn( $t ) => [ 'id' => 0, 'title' => $t ], [
+                __( 'Óbidos in a Day', 'clusterlane' ), __( 'Tasca do Chico', 'clusterlane' ), __( 'LX Factory Brunch', 'clusterlane' ),
+                __( 'Praia da Ursa', 'clusterlane' ), __( 'Miradouro da Graça at Dawn', 'clusterlane' ),
+            ] ),
+            'computed'   => $today->format( 'Y-m-d' ) . ' 03:17:00',
+            'thresholds' => [ 'green' => (int) ( $th['health_green'] ?? 70 ), 'orange' => (int) ( $th['health_orange'] ?? 50 ) ],
+            'log_since'  => $start->format( 'Y-m-d' ),
+        ];
+    }
+
+    public static function labels(): array {
+        return [
+            'pro'         => __( 'PRO', 'clusterlane' ),
+            'example'     => __( 'Example', 'clusterlane' ),
+            /* translators: 1: feature name, 2: demo site name */
+            'bannerText'  => __( '%1$s for a demo site (%2$s). Clusterlane Pro does the same for your own clusters.', 'clusterlane' ),
+            'seePro'      => __( 'See Pro →', 'clusterlane' ),
+            'exampleOnly' => __( 'Example only', 'clusterlane' ),
+        ];
+    }
+
+    /** Same banner as clusterlaneProBanner() in JS. */
+    public static function banner_html( string $feature_label ): string {
+        $l = self::labels();
+        $url = \Clusterlane\Core\Edition::upgrade_url();
+        return '<div class="clusterlane-pro-example-banner" role="note">'
+            . '<span class="clusterlane-pro-badge">' . esc_html( $l['pro'] ) . '</span>'
+            . '<span class="clusterlane-pro-example-text"><strong>' . esc_html( $l['example'] ) . '</strong> — '
+            . esc_html( sprintf( $l['bannerText'], $feature_label, self::data()['site'] ) ) . '</span>'
+            . ' <a class="clusterlane-pro-example-link" href="' . esc_url( $url ) . '" target="_blank" rel="noopener">' . esc_html( $l['seePro'] ) . '</a>'
+            . '</div>';
+    }
+
+    /** Compact "part of Pro" card for settings sections that don't exist in Free. */
+    public static function note_html( string $feature, string $title, string $text ): string {
+        return '<div class="clusterlane-pro-note">'
+            . '<span class="clusterlane-pro-badge">' . esc_html( self::labels()['pro'] ) . '</span>'
+            . '<div><strong>' . esc_html( $title ) . '</strong>'
+            . '<p>' . esc_html( $text ) . ' ' . esc_html( \Clusterlane\Core\Edition::missing_message( $feature ) ) . '</p></div>'
+            . '<a class="clusterlane-pro-example-link" href="' . esc_url( \Clusterlane\Core\Edition::upgrade_url() ) . '" target="_blank" rel="noopener">' . esc_html( self::labels()['seePro'] ) . '</a>'
+            . '</div>';
+    }
+
+    /** AI Link Suggestions page body in Free: what the flow produces, as an example. */
+    public static function render_link_suggest(): void {
+        $ex    = self::data()['link_suggest'];
+        $place = [ 'beginning' => __( 'Near the beginning', 'clusterlane' ), 'middle' => __( 'In the middle', 'clusterlane' ), 'end' => __( 'Near the end', 'clusterlane' ) ];
+        ?>
+        <div class="clusterlane-panel-card clusterlane-pro-example clusterlane-ls2">
+            <?php echo wp_kses_post( self::banner_html( __( 'AI Link Suggestions', 'clusterlane' ) ) ); ?>
+            <p class="description" style="margin:12px 0 14px;">
+                <?php esc_html_e( 'Start with an article that needs links — orphans first — and Clusterlane finds the best related articles to link from. AI then writes three ready-to-use sentences for each: the anchor, the sentence that carries it and where it goes. Preview, apply — and undo if you change your mind.', 'clusterlane' ); ?>
+            </p>
+            <div class="clusterlane-ls2-chosen">
+                <span><?php esc_html_e( 'Finding links to', 'clusterlane' ); ?> <strong><?php echo esc_html( $ex['target_title'] ); ?></strong>
+                    <span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--orphan"></span><?php esc_html_e( 'Orphan', 'clusterlane' ); ?></span>
+                    <span class="clusterlane-tm-note"><?php esc_html_e( '0 inbound links', 'clusterlane' ); ?></span></span>
+            </div>
+            <div class="clusterlane-ls2-row">
+                <div class="clusterlane-ls2-row-head"><div class="clusterlane-ls2-row-main">
+                    <strong><?php echo esc_html( sprintf( /* translators: %s: text */ __( 'From %s', 'clusterlane' ), $ex['source_title'] ) ); ?></strong>
+                    <span class="clusterlane-ls2-row-meta"><span class="clusterlane-role-pill"><span class="clusterlane-role-dot clusterlane-role-dot--pillar"></span><?php esc_html_e( 'Pillar', 'clusterlane' ); ?></span></span>
+                </div></div>
+                <div class="clusterlane-ls2-row-body"><div class="clusterlane-ls2-options">
+                    <?php foreach ( $ex['suggestions'] as $v ):
+                        $sentence = str_replace( '[LINK]', '<span class="clusterlane-tm-hit">' . esc_html( $v['anchor_text'] ) . '</span>', esc_html( $v['context'] ) ); ?>
+                    <div class="clusterlane-ls2-option">
+                        <p class="clusterlane-ls2-sentence"><?php echo wp_kses( $sentence, [ 'span' => [ 'class' => [] ] ] ); ?></p>
+                        <div class="clusterlane-ls2-option-foot">
+                            <span class="clusterlane-tm-note"><?php echo esc_html( $place[ $v['position'] ] ?? '' ); ?></span>
+                            <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" disabled title="<?php echo esc_attr( self::labels()['exampleOnly'] ); ?>"><?php esc_html_e( 'Preview & apply', 'clusterlane' ); ?></button>
+                        </div>
+                    </div>
+                    <?php endforeach; ?>
+                </div></div>
+            </div>
+        </div>
+        <?php
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-report-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-report-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-report-page.php	(revision 3751027)
@@ -0,0 +1,755 @@
+<?php
+namespace Clusterlane\Admin;
+
+use Clusterlane\Core\Activity;
+use Clusterlane\Core\Brand;
+use Clusterlane\Core\Edition;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Report page (0.98.125) — a calm, printable summary of the site: where it
+ * stands, how it has changed over the period and what has been done.
+ *
+ * Rendered on the server (charts are inline SVG), so it prints and saves as
+ * PDF without scripts. The only script, report.js, binds the Print button
+ * and — in Pro — the AI summary.
+ *
+ * Pro feature ('report'). This renderer is free code; the figures for the
+ * user's own site come from premium code through the clusterlane_report_data
+ * filter (Report::build() in pro__premium_only/). Without it the page shows
+ * the same report for the demo site, labelled as an example — the pattern
+ * of the AI examples. The history itself is collected in every edition,
+ * so an upgrade shows it straight away.
+ */
+class ReportPage {
+
+    const ROLE_ORDER = [ 'pillar', 'support', 'light', 'orphan' ];
+
+    /** Same colours as the Dashboard's archetype mix. */
+    const ARCHETYPE_COLORS = [
+        'Authority Pyramid'    => '#3b82f6',
+        'Balanced Mesh'        => '#10b981',
+        'Developed Cluster'    => '#0ea5e9',
+        'Over-Centralized Hub' => '#ef4444',
+        'Fragmented'           => '#f59e0b',
+        'Weak Structure'       => '#8b5cf6',
+        'Emerging Cluster'     => '#64748b',
+        'Mixed'                => '#94a3b8',
+        'Unclassified'         => '#cbd5e1',
+    ];
+
+    /** @var array<string,mixed> */
+    private array $r = [];
+
+    /** Showing the demo-site example (no Pro): no links, nothing to click. */
+    private bool $example = false;
+
+    /** Period length in days => label. */
+    public static function periods(): array {
+        return [
+            28  => __( '4 weeks', 'clusterlane' ),
+            84  => __( '12 weeks', 'clusterlane' ),
+            182 => __( '6 months', 'clusterlane' ),
+        ];
+    }
+
+    public static function period( $days ): int {
+        $days = (int) $days;
+        return isset( self::periods()[ $days ] ) ? $days : 84;
+    }
+
+    public function render(): void {
+        // A read-only view: the period is the only input.
+        $days = self::period( isset( $_GET['period'] ) ? absint( wp_unslash( $_GET['period'] ) ) : 84 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+        $this->example = ! Edition::has( 'report' );
+        $r = $this->example
+            ? ProExamples::report( $days )
+            : apply_filters( 'clusterlane_report_data', null, $days ); // premium: Report::build()
+        ?>
+        <div class="wrap clusterlane-wrap clusterlane-rp<?php echo $this->example ? ' clusterlane-rp--example' : ''; ?>">
+            <?php if ( function_exists( 'clusterlane_render_sticky_bar' ) ) clusterlane_render_sticky_bar( 'report' ); ?>
+            <?php
+            if ( ! is_array( $r ) ) {
+                $this->render_empty();
+                echo '</div>';
+                return;
+            }
+            $this->r = $r;
+            if ( $this->example ) {
+                echo '<div class="clusterlane-rp-example-note">' . wp_kses_post( ProExamples::banner_html( __( 'A site report', 'clusterlane' ) ) ) . '</div>';
+            }
+            $this->render_header();
+            ?>
+            <div class="clusterlane-rp-grid clusterlane-rp-grid--trend">
+                <?php $this->render_trend(); ?>
+                <?php $this->render_work(); ?>
+            </div>
+            <?php $this->render_clusters(); ?>
+            <?php $this->render_composition(); ?>
+            <div class="clusterlane-rp-grid clusterlane-rp-grid--two">
+                <?php $this->render_todo(); ?>
+                <?php $this->render_summary(); ?>
+            </div>
+            <?php $this->render_footer(); ?>
+        </div>
+        <?php
+    }
+
+    /* ------------------------------------------------------------------
+       Sections
+    ------------------------------------------------------------------ */
+
+    private function render_empty(): void {
+        ?>
+        <div class="clusterlane-page-hero">
+            <?php clusterlane_icon_e( 'report' ); ?>
+            <div>
+                <h1><?php esc_html_e( 'Report', 'clusterlane' ); ?></h1>
+                <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'A printable summary of the site: where it stands, how it has changed and what has been done.', 'clusterlane' ); ?></span>
+            </div>
+        </div>
+        <section class="clusterlane-rp-card">
+            <p class="clusterlane-rp-empty"><?php esc_html_e( 'Nothing to report yet. Click Scan in the bar above; the report fills in right after.', 'clusterlane' ); ?></p>
+        </section>
+        <?php
+    }
+
+    private function render_header(): void {
+        $r    = $this->r;
+        $site = $r['now']['site'];
+        $base = $r['base']['site'] ?? null;
+        $host = (string) ( $r['host'] ?? wp_parse_url( home_url(), PHP_URL_HOST ) );
+        $act  = $r['activity']['total'];
+        $url  = admin_url( 'admin.php?page=clusterlane-report' );
+
+        if ( ! $r['base'] ) {
+            $compare = __( 'no earlier snapshot to compare with yet', 'clusterlane' );
+        } elseif ( $r['partial'] ) {
+            /* translators: %s: date */
+            $compare = sprintf( __( 'compared with %s, when the history starts', 'clusterlane' ), $this->date( $r['base']['date'] ) );
+        } else {
+            /* translators: %s: date */
+            $compare = sprintf( __( 'compared with %s', 'clusterlane' ), $this->date( $r['base']['date'] ) );
+        }
+        ?>
+        <header class="clusterlane-rp-head">
+            <div class="clusterlane-rp-head__top">
+                <div class="clusterlane-rp-head__title">
+                    <span class="clusterlane-rp-head__mark"><?php Brand::mark_e( 44, '#fff', Brand::INK ); ?></span>
+                    <div>
+                        <div class="clusterlane-rp-eyebrow"><?php echo esc_html( (string) ( $r['site_name'] ?? get_bloginfo( 'name' ) ) ); ?></div>
+                        <h1><?php esc_html_e( 'Site report', 'clusterlane' ); ?></h1>
+                        <div class="clusterlane-rp-head__sub">
+                            <?php
+                            // No history yet (first day): the period would read "16 Jul – 8 Oct" for a single day of data (0.98.185).
+                            $range = $r['base'] ? $this->date( $r['start'] ) . ' – ' . $this->date( $r['today'], true ) : $this->date( $r['today'], true );
+                            echo esc_html( $host . ' · ' . $range . ' · ' . $compare );
+                            ?>
+                        </div>
+                    </div>
+                </div>
+                <div class="clusterlane-rp-head__actions">
+                    <nav class="clusterlane-rp-periods" aria-label="<?php esc_attr_e( 'Period', 'clusterlane' ); ?>">
+                        <?php foreach ( self::periods() as $d => $label ) : ?>
+                            <a href="<?php echo esc_url( add_query_arg( 'period', $d, $url ) ); ?>"<?php echo $d === $r['days'] ? ' aria-current="page" class="is-active"' : ''; ?>><?php echo esc_html( $label ); ?></a>
+                        <?php endforeach; ?>
+                    </nav>
+                    <button type="button" class="clusterlane-rp-print" data-clusterlane-print<?php echo $this->example ? ' disabled' : ''; ?>><?php clusterlane_glyph_e( 'printer' ); ?><?php esc_html_e( 'Print / Save as PDF', 'clusterlane' ); ?></button>
+                </div>
+            </div>
+            <div class="clusterlane-rp-kpis">
+                <?php
+                $this->kpi( __( 'Average health', 'clusterlane' ), $site['health'] . '%', $base ? $this->delta( $site['health'] - $base['health'], 'up', __( 'pts', 'clusterlane' ) ) : '',
+                    /* translators: %s: value */
+                    $base ? sprintf( __( 'was %s', 'clusterlane' ), $base['health'] . '%' ) : __( 'weighted by articles', 'clusterlane' ) );
+                $this->kpi( __( 'Orphans', 'clusterlane' ), number_format_i18n( $site['orphans'] ), $base ? $this->delta( $site['orphans'] - $base['orphans'], 'down' ) : '',
+                    /* translators: %s: value */
+                    $base ? sprintf( __( 'was %s', 'clusterlane' ), number_format_i18n( $base['orphans'] ) ) : __( 'articles nothing links to', 'clusterlane' ) );
+                $this->kpi( __( 'Links added', 'clusterlane' ), number_format_i18n( (int) ( $act[ Activity::LINK_ADDED ] ?? 0 ) ), '',
+                    __( 'through Clusterlane, each approved', 'clusterlane' ) );
+                $this->kpi( __( 'Avg inbound links', 'clusterlane' ), number_format_i18n( $site['avg_inbound'], 1 ), $base ? $this->delta( round( $site['avg_inbound'] - $base['avg_inbound'], 1 ), 'up', '', 1 ) : '',
+                    /* translators: %s: value */
+                    $base ? sprintf( __( 'per article, was %s', 'clusterlane' ), number_format_i18n( $base['avg_inbound'], 1 ) ) : __( 'per article', 'clusterlane' ) );
+                $this->kpi( __( 'Content', 'clusterlane' ), number_format_i18n( $site['articles'] ), '',
+                    $this->content_sub( $site, $base ) );
+                ?>
+            </div>
+        </header>
+        <?php
+    }
+
+    private function content_sub( array $site, ?array $base ): string {
+        /* translators: %s: number of clusters */
+        $clusters = sprintf( _n( '%s cluster', '%s clusters', $site['clusters'], 'clusterlane' ), number_format_i18n( $site['clusters'] ) );
+        if ( ! $base ) return __( 'articles', 'clusterlane' ) . ' · ' . $clusters;
+        $da = $site['articles'] - $base['articles'];
+        $dc = $site['clusters'] - $base['clusters'];
+        $parts = [];
+        /* translators: %s: signed number */
+        $parts[] = sprintf( __( '%s articles', 'clusterlane' ), $this->signed( $da ) );
+        $parts[] = $clusters . ( $dc ? ' (' . $this->signed( $dc ) . ')' : '' );
+        return implode( ' · ', $parts );
+    }
+
+    private function render_trend(): void {
+        $r      = $this->r;
+        $series = $r['series'];
+        ?>
+        <section class="clusterlane-rp-card clusterlane-rp-trend" aria-labelledby="clusterlane-rp-trend-h">
+            <div class="clusterlane-rp-card__head">
+                <h2 id="clusterlane-rp-trend-h"><?php esc_html_e( 'Cluster health over time', 'clusterlane' ); ?></h2>
+                <?php if ( count( $series ) >= 2 ) : ?>
+                <div class="clusterlane-rp-legend">
+                    <span><i class="clusterlane-rp-key clusterlane-rp-key--line"></i><?php esc_html_e( 'Average health', 'clusterlane' ); ?></span>
+                    <span><i class="clusterlane-rp-key clusterlane-rp-key--dash"></i><?php esc_html_e( 'Orphans', 'clusterlane' ); ?></span>
+                </div>
+                <?php endif; ?>
+            </div>
+            <?php if ( count( $series ) >= 2 ) : ?>
+                <div class="clusterlane-rp-chart"><?php echo wp_kses( $this->line_chart( $series ), self::kses_chart() ); ?></div>
+                <p class="clusterlane-rp-note"><?php esc_html_e( 'One point per day from the daily snapshots; today is measured live. Orphans use their own scale.', 'clusterlane' ); ?></p>
+            <?php else : ?>
+                <div class="clusterlane-rp-building">
+                    <?php clusterlane_glyph_e( 'chart' ); ?>
+                    <p>
+                        <?php
+                        /* translators: %s: date */
+                        echo esc_html( sprintf( __( 'The history starts on %s. Clusterlane takes a snapshot every day, so the chart fills in from tomorrow.', 'clusterlane' ), $this->date( $r['first'] ?: $r['today'], true ) ) );
+                        ?>
+                    </p>
+                </div>
+            <?php endif; ?>
+        </section>
+        <?php
+    }
+
+    private function render_work(): void {
+        $a     = $this->r['activity'];
+        $tot   = $a['total'];
+        $by    = $a['by_tool'][ Activity::LINK_ADDED ] ?? [];
+        $links = (int) ( $tot[ Activity::LINK_ADDED ] ?? 0 );
+        $rows  = [
+            [ __( 'Meta descriptions written', 'clusterlane' ), (int) ( $tot[ Activity::META_WRITTEN ] ?? 0 ) ],
+            [ __( 'Clusters with a new pillar', 'clusterlane' ), (int) ( $tot[ Activity::PILLAR_CHOSEN ] ?? 0 ) ],
+            [ __( 'Drafts created', 'clusterlane' ),            (int) ( $tot[ Activity::DRAFT_CREATED ] ?? 0 ) ],
+        ];
+        $week = $this->r['links_week'];
+        ?>
+        <section class="clusterlane-rp-card clusterlane-rp-work" aria-labelledby="clusterlane-rp-work-h">
+            <div class="clusterlane-rp-card__head"><h2 id="clusterlane-rp-work-h"><?php esc_html_e( 'Work done', 'clusterlane' ); ?></h2></div>
+            <dl class="clusterlane-rp-work__list">
+                <div class="clusterlane-rp-work__row clusterlane-rp-work__row--main"><dt><?php esc_html_e( 'Internal links added', 'clusterlane' ); ?></dt><dd><?php echo esc_html( number_format_i18n( $links ) ); ?></dd></div>
+                <?php if ( $links ) : ?>
+                    <div class="clusterlane-rp-work__row clusterlane-rp-work__row--sub"><dt><?php esc_html_e( 'via Text Match', 'clusterlane' ); ?></dt><dd><?php echo esc_html( number_format_i18n( (int) ( $by['text_match'] ?? 0 ) ) ); ?></dd></div>
+                    <div class="clusterlane-rp-work__row clusterlane-rp-work__row--sub"><dt><?php esc_html_e( 'via Link Suggestions', 'clusterlane' ); ?></dt><dd><?php echo esc_html( number_format_i18n( (int) ( $by['link_suggest'] ?? 0 ) ) ); ?></dd></div>
+                <?php endif; ?>
+                <?php foreach ( $rows as [ $label, $n ] ) : ?>
+                    <div class="clusterlane-rp-work__row"><dt><?php echo esc_html( $label ); ?></dt><dd><?php echo esc_html( number_format_i18n( $n ) ); ?></dd></div>
+                <?php endforeach; ?>
+            </dl>
+            <?php if ( array_sum( $week ) > 0 ) : ?>
+                <div class="clusterlane-rp-work__bars">
+                    <div class="clusterlane-rp-mini-h"><?php esc_html_e( 'Links added per week', 'clusterlane' ); ?></div>
+                    <?php echo wp_kses( $this->bar_chart( $week ), self::kses_chart() ); ?>
+                </div>
+            <?php endif; ?>
+            <p class="clusterlane-rp-note">
+                <?php
+                if ( ! empty( $a['undone'] ) ) {
+                    /* translators: %s: number */
+                    echo esc_html( sprintf( _n( 'Counted net: %s change was undone and is not included.', 'Counted net: %s changes were undone and are not included.', (int) $a['undone'], 'clusterlane' ), number_format_i18n( (int) $a['undone'] ) ) ) . ' ';
+                }
+                /* translators: %s: date */
+                echo esc_html( sprintf( __( 'Logged since %s.', 'clusterlane' ), $this->date( $this->log_since(), true ) ) );
+                ?>
+            </p>
+        </section>
+        <?php
+    }
+
+    private function log_since(): string {
+        if ( isset( $this->r['log_since'] ) ) return (string) $this->r['log_since'];
+        $first = Activity::stats()['first'];
+        $start = $this->r['start'];
+        if ( ! $first ) return $this->r['today'];
+        $local = wp_date( 'Y-m-d', (int) strtotime( $first . ' UTC' ) );
+        return max( $local, $start );
+    }
+
+    private function render_clusters(): void {
+        $r        = $this->r;
+        $clusters = $r['now']['clusters'];
+        $then     = [];
+        foreach ( (array) ( $r['base']['clusters'] ?? [] ) as $c ) $then[ $c['k'] ] = (int) $c['h'];
+        // Needs attention first: lowest health; empty clusters last.
+        usort( $clusters, static fn( $a, $b ) => [ $a['n'] === 0, $a['h'], $a['l'] ] <=> [ $b['n'] === 0, $b['h'], $b['l'] ] );
+        $shown = array_slice( $clusters, 0, 10 );
+        $rest  = array_slice( $clusters, 10 );
+        $ci    = admin_url( 'admin.php?page=clusterlane-cluster-inspector' );
+        ?>
+        <section class="clusterlane-rp-card" aria-labelledby="clusterlane-rp-cl-h">
+            <div class="clusterlane-rp-card__head">
+                <h2 id="clusterlane-rp-cl-h"><?php esc_html_e( 'Clusters', 'clusterlane' ); ?></h2>
+                <span class="clusterlane-rp-card__meta">
+                    <?php
+                    /* translators: %s: number */
+                    echo esc_html( sprintf( _n( '%s cluster · needs attention first', '%s clusters · needs attention first', count( $clusters ), 'clusterlane' ), number_format_i18n( count( $clusters ) ) ) );
+                    ?>
+                </span>
+            </div>
+            <div class="clusterlane-rp-table-wrap">
+                <table class="clusterlane-rp-table">
+                    <thead>
+                        <tr>
+                            <th scope="col"><?php esc_html_e( 'Cluster', 'clusterlane' ); ?></th>
+                            <th scope="col" class="num"><?php esc_html_e( 'Articles', 'clusterlane' ); ?></th>
+                            <th scope="col" class="clusterlane-rp-col-mix"><?php esc_html_e( 'Role mix', 'clusterlane' ); ?></th>
+                            <th scope="col" class="num"><?php esc_html_e( 'Orphans', 'clusterlane' ); ?></th>
+                            <th scope="col" class="num"><?php esc_html_e( 'Health', 'clusterlane' ); ?></th>
+                            <th scope="col" class="num clusterlane-rp-col-change"><?php esc_html_e( 'Change', 'clusterlane' ); ?></th>
+                            <?php if ( ! $this->example ) : ?><th scope="col" class="clusterlane-rp-col-quick"><span class="screen-reader-text"><?php esc_html_e( 'Quick view', 'clusterlane' ); ?></span></th><?php endif; ?>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <?php foreach ( $shown as $c ) $this->cluster_row( $c, $then, $ci ); ?>
+                    </tbody>
+                    <?php if ( $rest ) : ?>
+                    <tbody class="clusterlane-rp-more" hidden>
+                        <?php foreach ( $rest as $c ) $this->cluster_row( $c, $then, $ci ); ?>
+                    </tbody>
+                    <?php endif; ?>
+                </table>
+            </div>
+            <div class="clusterlane-rp-table-foot">
+                <?php if ( $rest ) : ?>
+                    <button type="button" class="clusterlane-rp-link" data-clusterlane-more aria-expanded="false">
+                        <?php
+                        /* translators: %s: number */
+                        echo esc_html( sprintf( __( 'Show all %s clusters', 'clusterlane' ), number_format_i18n( count( $clusters ) ) ) );
+                        ?>
+                    </button>
+                <?php endif; ?>
+                <div class="clusterlane-rp-legend clusterlane-rp-legend--roles">
+                    <?php foreach ( self::ROLE_ORDER as $role ) : ?>
+                        <span><i class="clusterlane-role-dot clusterlane-role-dot--<?php echo esc_attr( $role ); ?>"></i><?php echo esc_html( $this->role_label( $role ) ); ?></span>
+                    <?php endforeach; ?>
+                </div>
+            </div>
+        </section>
+        <?php
+    }
+
+    private function cluster_row( array $c, array $then, string $ci ): void {
+        $h      = (int) $c['h'];
+        $n      = (int) $c['n'];
+        $change = isset( $then[ $c['k'] ] ) ? $h - $then[ $c['k'] ] : null;
+        ?>
+        <tr>
+            <td>
+                <?php echo wp_kses_post( $this->a( add_query_arg( 'open', rawurlencode( $c['k'] ), $ci ), $c['l'], 'clusterlane-rp-cl-name' ) ); ?>
+                <span class="clusterlane-rp-cl-arch"><?php echo esc_html( $c['a'] === 'Unclassified' ? __( 'Not computed yet', 'clusterlane' ) : $c['a'] ); ?></span>
+            </td>
+            <td class="num"><?php echo esc_html( number_format_i18n( $n ) ); ?></td>
+            <td class="clusterlane-rp-col-mix"><?php $this->mix_bar( (array) $c['r'], $n ); ?></td>
+            <td class="num<?php echo ! empty( $c['r']['orphan'] ) ? ' clusterlane-rp-bad' : ' clusterlane-rp-muted'; ?>"><?php echo esc_html( number_format_i18n( (int) ( $c['r']['orphan'] ?? 0 ) ) ); ?></td>
+            <td class="num"><span class="clusterlane-rp-health clusterlane-rp-health--<?php echo esc_attr( $n ? $this->band( $h ) : 'none' ); ?>"><?php echo $n ? esc_html( $h . '%' ) : '—'; ?></span></td>
+            <td class="num clusterlane-rp-col-change">
+                <?php
+                if ( $change !== null )       echo wp_kses_post( $this->delta( $change, 'up', __( 'pts', 'clusterlane' ) ) );
+                elseif ( $this->r['base'] )   echo '<span class="clusterlane-rp-muted">' . esc_html__( 'new', 'clusterlane' ) . '</span>'; // not in the earlier snapshot
+                else                          echo '<span class="clusterlane-rp-muted">—</span>';
+                ?>
+            </td>
+            <?php if ( ! $this->example ) : ?>
+            <td class="clusterlane-rp-col-quick">
+                <button type="button" class="clusterlane-rp-quick" data-clusterlane-quick="<?php echo esc_attr( $c['k'] ); ?>" data-clusterlane-quick-label="<?php echo esc_attr( $c['l'] ); ?>"
+                    title="<?php esc_attr_e( 'Quick view', 'clusterlane' ); ?>"
+                    aria-label="<?php /* translators: %s: cluster name */ echo esc_attr( sprintf( __( 'Quick view: %s', 'clusterlane' ), $c['l'] ) ); ?>"><?php clusterlane_glyph_e( 'eye' ); ?></button>
+            </td>
+            <?php endif; ?>
+        </tr>
+        <?php
+    }
+
+    private function mix_bar( array $roles, int $n ): void {
+        $total = array_sum( array_map( 'intval', $roles ) );
+        if ( ! $total ) { echo '<span class="clusterlane-rp-muted">—</span>'; return; }
+        $title = [];
+        echo '<span class="clusterlane-rp-mix" role="img" aria-label="';
+        foreach ( self::ROLE_ORDER as $role ) $title[] = $this->role_label( $role ) . ' ' . (int) ( $roles[ $role ] ?? 0 );
+        echo esc_attr( implode( ', ', $title ) ) . '">';
+        foreach ( self::ROLE_ORDER as $role ) {
+            $v = (int) ( $roles[ $role ] ?? 0 );
+            if ( ! $v ) continue;
+            echo '<span class="clusterlane-rp-mix__seg clusterlane-rp-mix__seg--' . esc_attr( $role ) . '" style="flex-grow:' . esc_attr( (string) $v ) . '"></span>';
+        }
+        echo '</span>';
+    }
+
+    private function render_composition(): void {
+        $s     = $this->r['now']['site'];
+        $roles = $s['roles'];
+        $meta  = $s['meta'];
+        $arch  = (array) $s['archetypes'];
+        arsort( $arch );
+        $rows = [
+            [
+                __( 'Article roles', 'clusterlane' ),
+                /* translators: %s: number */
+                sprintf( __( '%s articles, site-wide role', 'clusterlane' ), number_format_i18n( array_sum( $roles ) ) ),
+                array_map( fn( $role ) => [ $this->role_label( $role ), (int) $roles[ $role ], 'var(--clusterlane-rp-' . $role . ')' ], self::ROLE_ORDER ),
+            ],
+            [
+                __( 'Meta descriptions', 'clusterlane' ),
+                __( 'from your SEO plugin or fields', 'clusterlane' ),
+                [
+                    [ __( 'Good', 'clusterlane' ),    (int) $meta['good'],    '#15803d' ],
+                    [ __( 'Weak', 'clusterlane' ),    (int) $meta['weak'],    '#f59e0b' ],
+                    [ __( 'Missing', 'clusterlane' ), (int) $meta['missing'], '#dc2626' ],
+                ],
+            ],
+            [
+                __( 'Cluster archetypes', 'clusterlane' ),
+                /* translators: %s: number */
+                sprintf( _n( '%s cluster', '%s clusters', array_sum( $arch ), 'clusterlane' ), number_format_i18n( array_sum( $arch ) ) ),
+                array_map( fn( $name, $n ) => [ $name, (int) $n, self::ARCHETYPE_COLORS[ $name ] ?? '#94a3b8' ], array_keys( $arch ), array_values( $arch ) ),
+            ],
+        ];
+        ?>
+        <section class="clusterlane-rp-card" aria-labelledby="clusterlane-rp-comp-h">
+            <div class="clusterlane-rp-card__head"><h2 id="clusterlane-rp-comp-h"><?php esc_html_e( 'Site composition', 'clusterlane' ); ?></h2></div>
+            <div class="clusterlane-rp-comp">
+                <?php foreach ( $rows as [ $title, $sub, $parts ] ) :
+                    $total = array_sum( array_column( $parts, 1 ) ); ?>
+                    <div class="clusterlane-rp-comp__label"><strong><?php echo esc_html( $title ); ?></strong><span><?php echo esc_html( $sub ); ?></span></div>
+                    <div class="clusterlane-rp-comp__bar" role="img" aria-label="<?php echo esc_attr( implode( ', ', array_map( fn( $p ) => $p[0] . ' ' . $p[1], $parts ) ) ); ?>">
+                        <?php foreach ( $parts as [ $name, $n, $color ] ) : if ( ! $n ) continue; ?>
+                            <span style="flex-grow:<?php echo esc_attr( (string) $n ); ?>;background:<?php echo esc_attr( $color ); ?>"></span>
+                        <?php endforeach; ?>
+                    </div>
+                    <div class="clusterlane-rp-comp__legend">
+                        <?php foreach ( $parts as [ $name, $n, $color ] ) : if ( ! $n ) continue; ?>
+                            <span><i style="background:<?php echo esc_attr( $color ); ?>"></i><?php echo esc_html( $name ); ?> <b><?php echo esc_html( number_format_i18n( $n ) ); ?></b><?php echo $total ? ' <em>' . esc_html( round( $n / $total * 100 ) . '%' ) . '</em>' : ''; ?></span>
+                        <?php endforeach; ?>
+                    </div>
+                <?php endforeach; ?>
+            </div>
+        </section>
+        <?php
+    }
+
+    private function render_todo(): void {
+        $r     = $this->r;
+        $s     = $r['now']['site'];
+        $limit = $r['thresholds']['orange'];
+        $ai    = admin_url( 'admin.php?page=clusterlane-article-inspector' );
+        $ci    = admin_url( 'admin.php?page=clusterlane-cluster-inspector' );
+        $weak  = array_values( array_filter( $r['now']['clusters'], fn( $c ) => $c['n'] > 0 && $c['h'] < $limit ) );
+        usort( $weak, fn( $a, $b ) => $a['h'] <=> $b['h'] );
+
+        $items = [];
+        if ( $s['orphans'] > 0 ) {
+            $items[] = [
+                /* translators: %s: number */
+                sprintf( _n( 'Link to %s orphan article', 'Link to %s orphan articles', $s['orphans'], 'clusterlane' ), number_format_i18n( $s['orphans'] ) ),
+                array_map( fn( $o ) => [ $o['title'], add_query_arg( 'post_id', $o['id'], $ai ) ], $r['orphan_list'] ),
+                add_query_arg( 'role', 'orphan', $ai ),
+            ];
+        }
+        if ( $weak ) {
+            $items[] = [
+                /* translators: 1: number, 2: percentage */
+                sprintf( _n( 'Lift %1$s cluster above %2$s health', 'Lift %1$s clusters above %2$s health', count( $weak ), 'clusterlane' ), number_format_i18n( count( $weak ) ), $limit . '%' ),
+                array_map( fn( $c ) => [ $c['l'] . ' (' . $c['h'] . '%)', add_query_arg( 'open', rawurlencode( $c['k'] ), $ci ) ], array_slice( $weak, 0, 4 ) ),
+                $ci,
+            ];
+        }
+        if ( $s['meta']['missing'] > 0 || $s['meta']['weak'] > 0 ) {
+            $title = $s['meta']['missing'] > 0
+                /* translators: %s: number */
+                ? sprintf( _n( 'Write %s missing meta description', 'Write %s missing meta descriptions', $s['meta']['missing'], 'clusterlane' ), number_format_i18n( $s['meta']['missing'] ) )
+                /* translators: %s: number */
+                : sprintf( _n( 'Improve %s weak meta description', 'Improve %s weak meta descriptions', $s['meta']['weak'], 'clusterlane' ), number_format_i18n( $s['meta']['weak'] ) );
+            $note = ( $s['meta']['missing'] > 0 && $s['meta']['weak'] > 0 )
+                /* translators: %s: number */
+                ? sprintf( _n( 'and improve %s that is too short or too long', 'and improve %s that are too short or too long', $s['meta']['weak'], 'clusterlane' ), number_format_i18n( $s['meta']['weak'] ) )
+                : '';
+            // To SEO Titles, the page where descriptions are written (0.98.185; was Article Inspector).
+            $items[] = [ $title, $note, add_query_arg( 'meta', $s['meta']['missing'] > 0 ? 'missing' : 'weak', admin_url( 'admin.php?page=clusterlane-seo-titles' ) ) ];
+        }
+        ?>
+        <section class="clusterlane-rp-card" aria-labelledby="clusterlane-rp-todo-h">
+            <div class="clusterlane-rp-card__head"><h2 id="clusterlane-rp-todo-h"><?php esc_html_e( 'Still to do', 'clusterlane' ); ?></h2></div>
+            <?php if ( ! $items ) : ?>
+                <p class="clusterlane-rp-done"><?php clusterlane_glyph_e( 'check' ); ?>
+                    <?php
+                    /* translators: %s: percentage */
+                    echo esc_html( sprintf( __( 'Nothing urgent: every article has a link pointing to it, every cluster is above %s and every article has a good meta description.', 'clusterlane' ), $limit . '%' ) );
+                    ?>
+                </p>
+            <?php else : ?>
+                <ol class="clusterlane-rp-todo">
+                    <?php foreach ( $items as $i => [ $title, $detail, $link ] ) : ?>
+                        <li>
+                            <span class="clusterlane-rp-todo__n"><?php echo esc_html( (string) ( $i + 1 ) ); ?></span>
+                            <div>
+                                <?php echo wp_kses_post( $this->a( $link, $title, 'clusterlane-rp-todo__title' ) ); ?>
+                                <?php if ( is_array( $detail ) && $detail ) : ?>
+                                    <div class="clusterlane-rp-todo__detail">
+                                        <?php
+                                        $links = array_map( fn( $d ) => $this->a( $d[1], $d[0] ), $detail );
+                                        echo wp_kses_post( implode( ' · ', $links ) );
+                                        ?>
+                                    </div>
+                                <?php elseif ( is_string( $detail ) && $detail !== '' ) : ?>
+                                    <div class="clusterlane-rp-todo__detail"><?php echo esc_html( $detail ); ?></div>
+                                <?php endif; ?>
+                            </div>
+                        </li>
+                    <?php endforeach; ?>
+                </ol>
+            <?php endif; ?>
+        </section>
+        <?php
+    }
+
+    private function render_summary(): void {
+        $pro = ! $this->example && Edition::has( 'ai' ) && Edition::premium_code();
+        ?>
+        <section class="clusterlane-rp-card clusterlane-rp-summary" id="clusterlane-rp-summary" data-period="<?php echo esc_attr( (string) $this->r['days'] ); ?>" aria-labelledby="clusterlane-rp-sum-h">
+            <div class="clusterlane-rp-card__head">
+                <h2 id="clusterlane-rp-sum-h"><?php esc_html_e( 'Summary', 'clusterlane' ); ?></h2>
+                <span class="clusterlane-rp-ai-badge"><?php esc_html_e( 'AI', 'clusterlane' ); ?></span>
+            </div>
+            <?php if ( $pro ) : ?>
+                <div class="clusterlane-rp-summary__body" data-clusterlane-summary-body>
+                    <p class="clusterlane-rp-note"><?php esc_html_e( 'A short summary of this report in plain words, written by AI from the figures above — for a client, a manager or your own notes.', 'clusterlane' ); ?></p>
+                </div>
+                <div class="clusterlane-rp-summary__actions">
+                    <button type="button" class="clusterlane-action-btn clusterlane-action-btn--primary" data-clusterlane-summary-run><?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'Write summary with AI', 'clusterlane' ); ?></button>
+                    <span class="clusterlane-rp-note" data-clusterlane-summary-meta></span>
+                </div>
+            <?php else : ?>
+                <?php
+                if ( ! $this->example ) echo wp_kses_post( ProExamples::banner_html( __( 'A report summary', 'clusterlane' ) ) );
+                $ex = ProExamples::data()['report_summary'] ?? [];
+                ?>
+                <div class="clusterlane-rp-summary__body clusterlane-pro-example">
+                    <?php foreach ( (array) ( $ex['paragraphs'] ?? [] ) as $p ) : ?>
+                        <p><?php echo esc_html( $p ); ?></p>
+                    <?php endforeach; ?>
+                </div>
+            <?php endif; ?>
+        </section>
+        <?php
+    }
+
+    private function render_footer(): void {
+        $docs = Brand::url( 'docs' );
+        ?>
+        <footer class="clusterlane-rp-foot">
+            <span>
+                <?php
+                /* translators: 1: plugin name, 2: version, 3: date and time */
+                echo esc_html( sprintf( __( 'Generated by %1$s %2$s · %3$s', 'clusterlane' ), Brand::name(), CLUSTERLANE_VERSION, wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ) );
+                if ( $this->r['computed'] ) {
+                    /* translators: %s: date */
+                    echo ' · ' . esc_html( sprintf( __( 'cluster health as calculated on %s', 'clusterlane' ), $this->date( substr( (string) $this->r['computed'], 0, 10 ), true ) ) );
+                }
+                ?>
+            </span>
+            <?php if ( $docs ) : ?>
+                <a href="<?php echo esc_url( $docs ); ?>" target="_blank" rel="noopener"><?php esc_html_e( 'How health, roles and archetypes are measured', 'clusterlane' ); ?></a>
+            <?php endif; ?>
+        </footer>
+        <?php
+    }
+
+    /* ------------------------------------------------------------------
+       Charts (inline SVG)
+    ------------------------------------------------------------------ */
+
+    /** Health (solid) and orphans (dashed, own scale) per day across the period. */
+    private function line_chart( array $series ): string {
+        $days  = $this->r['days'];
+        $start = new \DateTimeImmutable( $this->r['start'], wp_timezone() );
+        $x0 = 64; $x1 = 760; $y0 = 24; $y1 = 194;
+
+        $hs   = array_column( $series, 'health' );
+        $os   = array_column( $series, 'orphans' );
+        $hmin = max( 0, (int) floor( ( min( $hs ) - 5 ) / 10 ) * 10 );
+        $hmax = min( 100, (int) ceil( ( max( $hs ) + 5 ) / 10 ) * 10 );
+        // A span of 20, 40, 60 … so the four gridlines land on round numbers (5, 10, 15 … apart).
+        $span = max( 20, (int) ceil( ( $hmax - $hmin ) / 20 ) * 20 );
+        $hmax = min( 100, $hmin + $span );
+        $hmin = $hmax - $span;
+        $omax = max( 4, (int) ceil( max( $os ) * 1.2 ) );
+
+        $x  = function ( string $date ) use ( $start, $days, $x0, $x1 ): float {
+            $d = ( new \DateTimeImmutable( $date, wp_timezone() ) )->getTimestamp() - $start->getTimestamp();
+            return round( $x0 + max( 0, min( 1, $d / ( $days * DAY_IN_SECONDS ) ) ) * ( $x1 - $x0 ), 1 );
+        };
+        $yh = fn( $v ) => round( $y1 - ( $v - $hmin ) / ( $hmax - $hmin ) * ( $y1 - $y0 ), 1 );
+        $yo = fn( $v ) => round( $y1 - $v / $omax * ( $y1 - $y0 ), 1 );
+
+        $hp = []; $op = [];
+        foreach ( $series as $p ) {
+            $px   = $x( $p['snap_date'] );
+            $hp[] = $px . ',' . $yh( $p['health'] );
+            $op[] = $px . ',' . $yo( $p['orphans'] );
+        }
+        $first = reset( $series ); $last = end( $series );
+        $lx    = $x( $last['snap_date'] );
+
+        $label = sprintf(
+            /* translators: 1: percentage, 2: percentage, 3: number, 4: number */
+            __( 'Average health from %1$s to %2$s; orphans from %3$s to %4$s.', 'clusterlane' ),
+            $first['health'] . '%', $last['health'] . '%', $first['orphans'], $last['orphans']
+        );
+        $svg = '<svg viewBox="0 0 800 244" role="img" aria-label="' . esc_attr( $label ) . '">';
+
+        // Grid + health scale.
+        $svg .= '<g class="clusterlane-rp-grid-lines">';
+        for ( $i = 0; $i <= 4; $i++ ) {
+            $v = $hmin + ( $hmax - $hmin ) * $i / 4;
+            $y = $yh( $v );
+            $svg .= '<line x1="' . $x0 . '" y1="' . $y . '" x2="' . $x1 . '" y2="' . $y . '"></line>';
+            $svg .= '<text x="' . ( $x0 - 10 ) . '" y="' . ( $y + 4 ) . '" text-anchor="end">' . esc_html( round( $v ) . '%' ) . '</text>';
+        }
+        $svg .= '</g>';
+
+        // Dates along the bottom: five ticks across the period.
+        $svg .= '<g class="clusterlane-rp-axis">';
+        for ( $i = 0; $i <= 4; $i++ ) {
+            $t  = $start->modify( '+' . (int) round( $days * $i / 4 ) . ' days' );
+            $tx = round( $x0 + ( $x1 - $x0 ) * $i / 4, 1 );
+            $anchor = $i === 0 ? 'start' : ( $i === 4 ? 'end' : 'middle' );
+            $svg .= '<text x="' . $tx . '" y="' . ( $y1 + 26 ) . '" text-anchor="' . $anchor . '">' . esc_html( wp_date( 'j M', $t->getTimestamp() ) ) . '</text>';
+        }
+        $svg .= '</g>';
+
+        // Calculation changed: metrics_version differs from the previous point.
+        $prev = null;
+        foreach ( $series as $p ) {
+            if ( $prev !== null && (int) $p['metrics_version'] !== $prev ) {
+                $vx = $x( $p['snap_date'] );
+                $svg .= '<line class="clusterlane-rp-version" x1="' . $vx . '" y1="' . $y0 . '" x2="' . $vx . '" y2="' . $y1 . '"></line>';
+                $svg .= '<text class="clusterlane-rp-version-label" x="' . ( $vx + 5 ) . '" y="' . ( $y0 + 10 ) . '">' . esc_html__( 'Calculation changed', 'clusterlane' ) . '</text>';
+            }
+            $prev = (int) $p['metrics_version'];
+        }
+
+        $fx = $x( $first['snap_date'] );
+        $svg .= '<polygon class="clusterlane-rp-area" points="' . $fx . ',' . $y1 . ' ' . implode( ' ', $hp ) . ' ' . $lx . ',' . $y1 . '"></polygon>';
+        $svg .= '<polyline class="clusterlane-rp-line-orphans" points="' . implode( ' ', $op ) . '"></polyline>';
+        $svg .= '<polyline class="clusterlane-rp-line-health" points="' . implode( ' ', $hp ) . '"></polyline>';
+        $svg .= '<circle class="clusterlane-rp-dot-health" cx="' . $lx . '" cy="' . $yh( $last['health'] ) . '" r="5"></circle>';
+        $svg .= '<circle class="clusterlane-rp-dot-orphans" cx="' . $lx . '" cy="' . $yo( $last['orphans'] ) . '" r="4"></circle>';
+
+        // End labels; the orphans label moves below its dot when the two would collide.
+        $hy = $yh( $last['health'] ); $oy = $yo( $last['orphans'] );
+        $svg .= '<text class="clusterlane-rp-end-health" x="' . ( $lx - 9 ) . '" y="' . ( $hy - 10 ) . '" text-anchor="end">' . esc_html( $last['health'] . '%' ) . '</text>';
+        /* translators: %s: number */
+        $olabel = sprintf( _n( '%s orphan', '%s orphans', (int) $last['orphans'], 'clusterlane' ), number_format_i18n( (int) $last['orphans'] ) );
+        $oty    = abs( $oy - $hy ) < 22 ? $oy + 18 : $oy - 8;
+        $svg .= '<text class="clusterlane-rp-end-orphans" x="' . ( $lx - 9 ) . '" y="' . $oty . '" text-anchor="end">' . esc_html( $olabel ) . '</text>';
+
+        return $svg . '</svg>';
+    }
+
+    /** Small bars, one per week. */
+    private function bar_chart( array $values ): string {
+        $n   = count( $values );
+        $w   = 340; $h = 84; $base = 70;
+        $max = max( 1, max( $values ) );
+        $slot = $w / $n;
+        $bw   = max( 3, min( 20, $slot * 0.62 ) );
+        /* translators: 1: number, 2: number */
+        $label = sprintf( __( 'Links added per week, from %1$s to %2$s.', 'clusterlane' ), min( $values ), max( $values ) );
+        $svg = '<svg viewBox="0 0 ' . $w . ' ' . $h . '" role="img" aria-label="' . esc_attr( $label ) . '">';
+        $svg .= '<line class="clusterlane-rp-bars-base" x1="0" y1="' . $base . '" x2="' . $w . '" y2="' . $base . '"></line>';
+        foreach ( $values as $i => $v ) {
+            if ( ! $v ) continue;
+            $bh = round( $v / $max * ( $base - 12 ), 1 );
+            $bx = round( $i * $slot + ( $slot - $bw ) / 2, 1 );
+            $svg .= '<rect class="clusterlane-rp-bar" x="' . $bx . '" y="' . ( $base - $bh ) . '" width="' . round( $bw, 1 ) . '" height="' . $bh . '" rx="2"></rect>';
+            if ( $v === $max ) {
+                $svg .= '<text class="clusterlane-rp-bar-max" x="' . round( $bx + $bw / 2, 1 ) . '" y="' . ( $base - $bh - 3 ) . '" text-anchor="middle">' . (int) $v . '</text>';
+            }
+        }
+        $start = new \DateTimeImmutable( $this->r['start'], wp_timezone() );
+        $svg .= '<text class="clusterlane-rp-bars-axis" x="0" y="' . ( $h - 1 ) . '">' . esc_html( wp_date( 'j M', $start->getTimestamp() ) ) . '</text>';
+        $svg .= '<text class="clusterlane-rp-bars-axis" x="' . $w . '" y="' . ( $h - 1 ) . '" text-anchor="end">' . esc_html__( 'this week', 'clusterlane' ) . '</text>';
+        return $svg . '</svg>';
+    }
+
+    /** wp_kses allowlist for the charts. */
+    private static function kses_chart(): array {
+        $shape = [ 'class' => true, 'x' => true, 'y' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true,
+                   'cx' => true, 'cy' => true, 'r' => true, 'rx' => true, 'width' => true, 'height' => true, 'points' => true ];
+        return [
+            'svg'      => [ 'viewbox' => true, 'role' => true, 'aria-label' => true, 'class' => true ],
+            'g'        => [ 'class' => true ],
+            'line'     => $shape, 'polyline' => $shape, 'polygon' => $shape, 'rect' => $shape, 'circle' => $shape,
+            'text'     => [ 'class' => true, 'x' => true, 'y' => true, 'text-anchor' => true ],
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       Small helpers
+    ------------------------------------------------------------------ */
+
+    /** A link — or plain text in the example (the demo site's articles and clusters don't exist here). */
+    private function a( string $url, string $text, string $class = '' ): string {
+        $c = $class !== '' ? ' class="' . esc_attr( $class ) . '"' : '';
+        return $this->example
+            ? '<span' . $c . '>' . esc_html( $text ) . '</span>'
+            : '<a' . $c . ' href="' . esc_url( $url ) . '">' . esc_html( $text ) . '</a>';
+    }
+
+    private function kpi( string $label, string $value, string $delta, string $sub ): void {
+        ?>
+        <div class="clusterlane-rp-kpi">
+            <div class="clusterlane-rp-kpi__label"><?php echo esc_html( $label ); ?></div>
+            <div class="clusterlane-rp-kpi__value"><span><?php echo esc_html( $value ); ?></span><?php echo wp_kses_post( $delta ); ?></div>
+            <div class="clusterlane-rp-kpi__sub"><?php echo esc_html( $sub ); ?></div>
+        </div>
+        <?php
+    }
+
+    /** Signed change, green when it goes the good way ($better: up|down). */
+    private function delta( float $d, string $better, string $unit = '', int $decimals = 0 ): string {
+        if ( abs( $d ) < ( $decimals ? 0.05 : 0.5 ) ) {
+            return '<span class="clusterlane-rp-delta clusterlane-rp-delta--flat">±0' . ( $unit ? ' ' . esc_html( $unit ) : '' ) . '</span>';
+        }
+        $good = ( $d > 0 ) === ( $better === 'up' );
+        return '<span class="clusterlane-rp-delta clusterlane-rp-delta--' . ( $good ? 'good' : 'bad' ) . '">'
+            . esc_html( $this->signed( $d, $decimals ) . ( $unit ? ' ' . $unit : '' ) ) . '</span>';
+    }
+
+    private function signed( float $n, int $decimals = 0 ): string {
+        $s = number_format_i18n( abs( $n ), $decimals );
+        return ( $n > 0 ? '+' : ( $n < 0 ? '−' : '' ) ) . $s;
+    }
+
+    private function band( int $h ): string {
+        $t = $this->r['thresholds'];
+        return $h >= $t['green'] ? 'good' : ( $h >= $t['orange'] ? 'fair' : 'weak' );
+    }
+
+    /** A local Y-m-d date as "7 Jul" (or "7 Jul 2026"). */
+    private function date( string $ymd, bool $year = false ): string {
+        $t = ( new \DateTimeImmutable( $ymd, wp_timezone() ) )->getTimestamp();
+        return wp_date( $year ? 'j M Y' : 'j M', $t );
+    }
+
+    private function role_label( string $role ): string {
+        return [
+            'pillar'  => __( 'Pillar', 'clusterlane' ),
+            'support' => __( 'Support', 'clusterlane' ),
+            'light'   => __( 'Light', 'clusterlane' ),
+            'orphan'  => __( 'Orphan', 'clusterlane' ),
+        ][ $role ] ?? $role;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-seo-titles-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-seo-titles-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-seo-titles-page.php	(revision 3751027)
@@ -0,0 +1,82 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * SeoTitlesPage (0.98.154) — every article's SEO title and meta description
+ * in one list: check them, edit by hand (free) or let AI suggest (Pro), and
+ * apply field by field. Data: GET inspector/articles?seo=1, writes: seo/apply
+ * and seo/undo (RestSeo), AI: seo/suggest through window.clusterlanePro.seoSuggest.
+ * All rendering is in admin/js/seo-titles.js.
+ */
+class SeoTitlesPage {
+
+    public function render(): void {
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php clusterlane_render_sticky_bar( 'seo-titles' ); ?>
+
+            <div class="clusterlane-page-hero">
+                <?php clusterlane_icon_e( 'seo' ); ?>
+                <div>
+                    <h1><?php esc_html_e( 'SEO Titles', 'clusterlane' ); ?></h1>
+                    <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'The title and description each article shows in search results — check them all in one place, edit them by hand or let AI suggest better ones.', 'clusterlane' ); ?></span>
+                </div>
+            </div>
+
+            <section class="clusterlane-panel-card clusterlane-seot" id="clusterlane-seot" aria-labelledby="clusterlane-seot-title">
+                <div class="clusterlane-browse__head">
+                    <h3 class="clusterlane-ai-section-title" id="clusterlane-seot-title"><?php clusterlane_glyph_e( 'search' ); ?><?php esc_html_e( 'Search results', 'clusterlane' ); ?><?php clusterlane_tip( 'seo_titles' ); ?></h3>
+                    <span class="clusterlane-browse__summary" id="clusterlane-seot-summary" aria-live="polite"></span>
+                    <div class="clusterlane-seg clusterlane-seot__view" id="clusterlane-seot-view" role="group" aria-label="<?php esc_attr_e( 'View', 'clusterlane' ); ?>">
+                        <button type="button" class="clusterlane-seg-btn" data-view="compact" aria-pressed="true"><?php esc_html_e( 'Compact', 'clusterlane' ); ?></button>
+                        <button type="button" class="clusterlane-seg-btn" data-view="full" aria-pressed="false"><?php esc_html_e( 'Full', 'clusterlane' ); ?></button>
+                    </div>
+                    <button type="button" class="clusterlane-csv-btn" id="clusterlane-seot-csv" title="<?php esc_attr_e( 'Download the list as a CSV file, with the filters you have set', 'clusterlane' ); ?>"><?php clusterlane_glyph_e( 'download' ); ?><?php esc_html_e( 'Export CSV', 'clusterlane' ); ?></button>
+                </div>
+
+                <div id="clusterlane-seot-note"></div>
+
+                <div class="clusterlane-browse__toolbar">
+                    <div class="clusterlane-browse__search">
+                        <?php clusterlane_glyph_e( 'search' ); ?>
+                        <input type="search" id="clusterlane-seot-q" placeholder="<?php esc_attr_e( 'Filter by title…', 'clusterlane' ); ?>" aria-label="<?php esc_attr_e( 'Filter articles by title', 'clusterlane' ); ?>" autocomplete="off">
+                    </div>
+                    <select id="clusterlane-seot-cluster" aria-label="<?php esc_attr_e( 'Cluster', 'clusterlane' ); ?>">
+                        <option value=""><?php esc_html_e( 'All clusters', 'clusterlane' ); ?></option>
+                    </select>
+                    <?php if ( \Clusterlane\Core\Lang::active() ) : // multilingual sites (0.98.168) ?>
+                    <select id="clusterlane-seot-lang" aria-label="<?php esc_attr_e( 'Language', 'clusterlane' ); ?>">
+                        <option value=""><?php esc_html_e( 'All languages', 'clusterlane' ); ?></option>
+                        <?php foreach ( \Clusterlane\Core\Lang::languages() as $code => $l ) : ?><option value="<?php echo esc_attr( $code ); ?>"><?php echo esc_html( $l['name'] ); ?></option><?php endforeach; ?>
+                    </select>
+                    <?php endif; ?>
+                    <div class="clusterlane-browse__roles clusterlane-seot__chips" id="clusterlane-seot-meta" role="group" aria-label="<?php esc_attr_e( 'Show', 'clusterlane' ); ?>">
+                        <button type="button" data-meta="" class="is-active"><?php esc_html_e( 'All', 'clusterlane' ); ?></button>
+                        <button type="button" data-meta="missing"><span class="clusterlane-seot__dot clusterlane-seot__dot--bad"></span><?php esc_html_e( 'No description', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-meta="weak"><span class="clusterlane-seot__dot clusterlane-seot__dot--warn"></span><?php esc_html_e( 'Weak description', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-meta="title"><span class="clusterlane-seot__dot clusterlane-seot__dot--warn"></span><?php esc_html_e( 'Long title', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-meta="dup"><span class="clusterlane-seot__dot clusterlane-seot__dot--warn"></span><?php esc_html_e( 'Duplicates', 'clusterlane' ); ?> <em></em></button>
+                        <button type="button" data-meta="ai"><span class="clusterlane-seot__dot clusterlane-seot__dot--ai"></span><?php esc_html_e( 'AI: poor match', 'clusterlane' ); ?> <em></em></button>
+                    </div>
+                </div>
+
+                <!-- Bulk bar: select, AI for the selected, apply every change on the page -->
+                <div class="clusterlane-seot__bulk" id="clusterlane-seot-bulk">
+                    <label class="clusterlane-seot__all"><input type="checkbox" id="clusterlane-seot-all"> <span id="clusterlane-seot-selected"><?php esc_html_e( 'Select all', 'clusterlane' ); ?></span></label>
+                    <div class="clusterlane-seot__bulk-actions">
+                        <button type="button" class="clusterlane-tm-btn" id="clusterlane-seot-check"><?php clusterlane_glyph_e( 'check' ); ?><span><?php esc_html_e( 'Check with AI', 'clusterlane' ); ?></span></button>
+                        <button type="button" class="clusterlane-tm-btn" id="clusterlane-seot-suggest" disabled><?php clusterlane_glyph_e( 'sparkle' ); ?><span><?php esc_html_e( 'Suggest with AI', 'clusterlane' ); ?></span></button>
+                        <button type="button" class="clusterlane-tm-btn clusterlane-tm-btn--apply" id="clusterlane-seot-apply-all" disabled><?php esc_html_e( 'Apply changes', 'clusterlane' ); ?></button>
+                    </div>
+                    <div class="clusterlane-seot__run" id="clusterlane-seot-run" aria-live="polite" hidden></div>
+                </div>
+
+                <div id="clusterlane-seot-example"></div>
+                <div class="clusterlane-seot__list" id="clusterlane-seot-rows" aria-live="polite"></div>
+                <div class="clusterlane-browse__pager" id="clusterlane-seot-pager"></div>
+            </section>
+        </div>
+        <?php
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-settings-page.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-settings-page.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-settings-page.php	(revision 3751027)
@@ -0,0 +1,1032 @@
+<?php
+namespace Clusterlane\Admin;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * SettingsPage — renders and saves the Clusterlane Settings admin page.
+ */
+class SettingsPage {
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function handle_save(): void {
+        $settings = $this->plugin->get('settings');
+        $this->save( $settings );
+    }
+
+    public function render(): void {
+        $settings = $this->plugin->get('settings');
+
+        if ( isset( $_GET['settings-updated'] ) ) {  // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag
+            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'clusterlane' )
+                . ( isset( $_GET['recomputed'] ) ? ' ' . esc_html__( 'Clusters were recalculated with the new thresholds.', 'clusterlane' ) : '' ) . '</p></div>';  // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag
+        }
+
+        $post_types        = $settings->post_types();
+        $taxonomies        = $settings->taxonomies();
+        $min_term_count    = $settings->min_term_count();
+        $batch_size        = (int) $settings->get( 'batch_size', 25 );
+        $title_truncation  = $settings->title_truncation();
+        $title_trunc_custom = $settings->title_truncation_custom();
+
+        $th = $settings->get( 'thresholds', [] );
+        $t  = wp_parse_args( $th, $this->default_thresholds() );
+
+        // Every post type with its own pages on the site: public, or publicly
+        // queryable (some plugins register public => false but still give the
+        // items front-end URLs). Media is left out. (0.98.129)
+        $all_post_types = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' );
+        unset( $all_post_types['attachment'] );
+        uasort( $all_post_types, static fn( $a, $b ) => ( $a->name === 'post' ? -2 : ( $a->name === 'page' ? -1 : 0 ) ) <=> ( $b->name === 'post' ? -2 : ( $b->name === 'page' ? -1 : 0 ) ) ?: strcasecmp( $a->label, $b->label ) );
+
+        $all_taxonomies = get_taxonomies( [ 'public' => true, 'show_ui' => true ], 'objects' );
+        ?>
+        <div class="wrap clusterlane-wrap">
+            <?php if ( function_exists( 'clusterlane_render_sticky_bar' ) ) clusterlane_render_sticky_bar( 'settings' ); ?>
+
+            <div class="clusterlane-page-hero">
+                <?php clusterlane_icon_e( 'settings' ); ?>
+                <div>
+                    <h1><?php esc_html_e( 'Settings', 'clusterlane' ); ?></h1>
+                    <span class="clusterlane-page-hero-sub"><?php esc_html_e( 'AI provider and access, what gets scanned, link styles — and advanced tuning.', 'clusterlane' ); ?></span>
+                </div>
+            </div>
+
+            <form method="post" action="" id="clusterlane-settings-form">
+                <?php wp_nonce_field( 'clusterlane_settings_save' ); ?>
+                <input type="hidden" name="clusterlane_tab" id="clusterlane-set-tab-field" value="<?php echo esc_attr( sanitize_key( $_GET['tab'] ?? 'general' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag ?>">
+
+                <nav class="clusterlane-set-tabs" role="tablist" aria-label="<?php esc_attr_e( 'Settings sections', 'clusterlane' ); ?>">
+                    <button type="button" role="tab" data-tab="general" aria-selected="true" class="is-active"><?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'General', 'clusterlane' ); ?><em></em></button>
+                    <button type="button" role="tab" data-tab="content" aria-selected="false"><?php clusterlane_glyph_e( 'search' ); ?><?php esc_html_e( 'Content', 'clusterlane' ); ?><em></em></button>
+                    <button type="button" role="tab" data-tab="styles" aria-selected="false"><?php clusterlane_glyph_e( 'card' ); ?><?php esc_html_e( 'Link styles', 'clusterlane' ); ?><em></em></button>
+                    <button type="button" role="tab" data-tab="advanced" aria-selected="false"><?php clusterlane_glyph_e( 'sliders' ); ?><?php esc_html_e( 'Advanced', 'clusterlane' ); ?><em></em></button>
+                </nav>
+
+                <div class="clusterlane-set-panel" data-tab="general" role="tabpanel">
+                <!-- AI SETTINGS -->
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'AI Settings', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <?php
+                    // Premium: SettingsAi renders AI provider/key/model, prompts and usage & limits.
+                    if ( \Clusterlane\Core\Edition::has( 'ai' ) && has_action( 'clusterlane_settings_ai_section' ) ) {
+                        do_action( 'clusterlane_settings_ai_section', $settings );
+                    } else {
+                        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+                        echo wp_kses_post( \Clusterlane\Admin\ProExamples::note_html( 'ai', __( 'AI features', 'clusterlane' ), __( 'Gap Analysis, content briefs and drafts, AI Link Suggestions, and AI analysis of your site, clusters and articles — with your own OpenAI or Anthropic key, usage tracking and limits.', 'clusterlane' ) ) );
+                    }
+                    ?>
+                    </div><!-- /clusterlane-section-body ai-settings -->
+                </section>
+
+                <!-- TOOLBAR (0.98.146) -->
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'layers' ); ?><?php esc_html_e( 'Toolbar', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                        <input type="hidden" name="clusterlane[toolbar_present]" value="1">
+                        <label><input type="checkbox" name="clusterlane[toolbar]" value="1" <?php checked( (bool) $settings->get( 'toolbar', true ) ); ?>> <?php esc_html_e( 'Show Clusterlane in the toolbar at the top of the screen', 'clusterlane' ); ?></label>
+                        <p class="description"><?php esc_html_e( 'Links to the main pages, and "Inspect this article" when you view or edit an article. Shown to everyone with Clusterlane access.', 'clusterlane' ); ?></p>
+                    </div>
+                </section>
+
+                <!-- WHO CAN CHANGE ARTICLES (0.98.132) -->
+                <?php
+                $write_caps = [ \Clusterlane\Core\Capabilities::LINKS => __( 'Add links to articles', 'clusterlane' ) ];
+                $write_caps[ \Clusterlane\Core\Capabilities::SEO ] = __( 'Write SEO title & description', 'clusterlane' ); // all editions since 0.98.154 (SEO Titles)
+                $write_roles = [];
+                foreach ( wp_roles()->roles as $slug => $role ) {
+                    if ( $slug === 'administrator' ) continue;
+                    $rc = $role['capabilities'] ?? [];
+                    if ( empty( $rc[ \Clusterlane\Core\Capabilities::USE ] ) && empty( $rc[ \Clusterlane\Core\Capabilities::MANAGE ] ) ) continue;
+                    $write_roles[ $slug ] = [ translate_user_role( $role['name'] ), $rc ];
+                }
+                ?>
+                <section class="clusterlane-settings-section clusterlane-set-card" id="clusterlane-set-access">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'lock' ); ?><?php esc_html_e( 'Who can change articles', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                        <p class="description" style="margin:0 0 10px;"><?php esc_html_e( 'Everyone with Clusterlane access can see the suggestions. Untick a role to let it review, skip and copy, but not write to the articles.', 'clusterlane' ); ?></p>
+                        <?php foreach ( array_keys( $write_caps ) as $cap ): ?>
+                            <input type="hidden" name="clusterlane[write_caps_present][]" value="<?php echo esc_attr( $cap ); ?>">
+                        <?php endforeach; ?>
+                        <table class="clusterlane-access-table">
+                            <thead><tr>
+                                <th scope="col"><?php esc_html_e( 'Role', 'clusterlane' ); ?></th>
+                                <?php foreach ( $write_caps as $label ): ?><th scope="col"><?php echo esc_html( $label ); ?></th><?php endforeach; ?>
+                            </tr></thead>
+                            <tbody>
+                                <tr class="is-fixed">
+                                    <td><?php echo esc_html( translate_user_role( 'Administrator' ) ); ?> <span class="clusterlane-access-note"><?php esc_html_e( '(always)', 'clusterlane' ); ?></span></td>
+                                    <?php foreach ( $write_caps as $label ): ?><td><input type="checkbox" checked disabled aria-label="<?php echo esc_attr( $label ); ?>"></td><?php endforeach; ?>
+                                </tr>
+                                <?php foreach ( $write_roles as $slug => [ $rname, $rc ] ): ?>
+                                <tr>
+                                    <td><?php echo esc_html( $rname ); ?></td>
+                                    <?php foreach ( $write_caps as $cap => $label ): ?>
+                                    <td><input type="checkbox" name="clusterlane[write_caps][<?php echo esc_attr( $slug ); ?>][]" value="<?php echo esc_attr( $cap ); ?>" <?php checked( ! empty( $rc[ $cap ] ) ); ?> aria-label="<?php echo esc_attr( $rname . ': ' . $label ); ?>"></td>
+                                    <?php endforeach; ?>
+                                </tr>
+                                <?php endforeach; ?>
+                            </tbody>
+                        </table>
+                        <?php if ( ! $write_roles ): ?>
+                            <p class="description"><?php esc_html_e( 'Only administrators have Clusterlane access on this site.', 'clusterlane' ); ?></p>
+                        <?php endif; ?>
+                        <p class="description" style="margin-top:10px;"><?php
+                            echo esc_html( isset( $write_caps[ \Clusterlane\Core\Capabilities::SEO ] )
+                                ? __( 'Applies to changes made through Clusterlane: Text Match, Link Suggestions, SEO Titles and SEO suggestions. The article itself is still edited in WordPress — a role that may edit a post can change its text and any SEO plugin fields there.', 'clusterlane' )
+                                : __( 'Applies to changes made through Clusterlane (Text Match). The article itself is still edited in WordPress — a role that may edit a post can change its text there.', 'clusterlane' ) );
+                        ?></p>
+                    </div>
+                </section>
+                </div>
+
+                <div class="clusterlane-set-panel" data-tab="content" role="tabpanel" hidden>
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'search' ); ?><?php esc_html_e( 'What gets scanned', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <p class="clusterlane-set-note"><?php clusterlane_glyph_e( 'nodes' ); ?><?php
+                        printf(
+                            /* translators: %s: link to Cluster Admin */
+                            esc_html__( 'Which categories and tags become clusters is set in %s.', 'clusterlane' ),
+                            '<a href="' . esc_url( admin_url( 'admin.php?page=clusterlane-cluster-settings' ) ) . '">' . esc_html__( 'Cluster Admin', 'clusterlane' ) . ' →</a>'
+                        ); ?></p>
+                    <table class="form-table" role="presentation">
+                        <tr>
+                            <th><?php esc_html_e( 'Post Types', 'clusterlane' ); ?></th>
+                            <td>
+                                <?php foreach ( $all_post_types as $pt ): ?>
+                                <label style="display:block;margin-bottom:4px;">
+                                    <input type="checkbox" name="clusterlane[post_types][]"
+                                           value="<?php echo esc_attr( $pt->name ); ?>"
+                                           <?php checked( in_array( $pt->name, $post_types, true ) ); ?>>
+                                    <?php echo esc_html( $pt->label ); ?>
+                                    <span style="color:#888;font-size:.8rem;">(<?php echo esc_html( $pt->name ); ?>)</span>
+                                </label>
+                                <?php endforeach; ?>
+                                <p class="description"><?php esc_html_e( 'Post types included in scans.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+                        <?php
+                        // Published pages (0.98.177): links and text the theme adds through its templates.
+                        $rendered = $settings->rendered_types();
+                        $density  = \Clusterlane\Core\PageReader::text_link_density( array_keys( $all_post_types ) );
+                        $page_log = \Clusterlane\Core\PageReader::log();
+                        ?>
+                        <tr id="clusterlane-rendered-row">
+                            <th><?php esc_html_e( 'Read the published pages', 'clusterlane' ); ?></th>
+                            <td>
+                                <p class="description" style="margin:0 0 8px;"><?php esc_html_e( 'Clusterlane reads the links in the article text. Many themes also link pages through lists, cards or “related” sections built from custom fields — those links are only on the published page. Tick a post type to read its published pages too (only the main content: menus, breadcrumbs, sidebars and the footer are left out). Reading takes about a second per page, four pages at a time.', 'clusterlane' ); ?></p>
+                                <?php foreach ( $all_post_types as $pt ):
+                                    $d   = $density[ $pt->name ] ?? [ 'posts' => 0, 'per_page' => 0.0 ];
+                                    $rec = \Clusterlane\Core\PageReader::is_recommended( $d );
+                                ?>
+                                <label style="display:block;margin-bottom:4px;" class="clusterlane-rendered-type" data-type="<?php echo esc_attr( $pt->name ); ?>">
+                                    <input type="checkbox" name="clusterlane[rendered_types][]" value="<?php echo esc_attr( $pt->name ); ?>" <?php checked( in_array( $pt->name, $rendered, true ) ); ?> <?php disabled( ! in_array( $pt->name, $post_types, true ) ); ?>>
+                                    <?php echo esc_html( $pt->label ); ?>
+                                    <span style="color:#888;font-size:.8rem;"><?php
+                                        /* translators: 1: links per page, e.g. 0.4, 2: number of pages */
+                                        echo esc_html( sprintf( __( '%1$s links in the text per page (%2$d pages)', 'clusterlane' ), number_format_i18n( $d['per_page'], 1 ), $d['posts'] ) );
+                                    ?></span>
+                                    <?php if ( $rec && ! in_array( $pt->name, $rendered, true ) ): ?><span class="clusterlane-rendered-rec"><?php esc_html_e( 'Recommended — almost no links in the text', 'clusterlane' ); ?></span><?php endif; ?>
+                                </label>
+                                <?php endforeach; ?>
+                                <label style="display:block;margin:10px 0 4px;" class="clusterlane-rendered-cached">
+                                    <input type="checkbox" name="clusterlane[rendered_cached]" value="1" <?php checked( (bool) $settings->get( 'rendered_cached', false ) ); ?>>
+                                    <?php esc_html_e( 'Use cached pages (faster)', 'clusterlane' ); ?>
+                                </label>
+                                <p class="description" style="margin:0 0 8px 24px;"><?php esc_html_e( 'Reads each page the way your page cache serves it to visitors — a fraction of a second instead of about a second per page. Tick this if your cache is cleared when you publish or update content; otherwise a changed list or card shows only once the cache has renewed that page. A cached answer that doesn’t show the article is read again, fresh.', 'clusterlane' ); ?></p>
+                                <p class="description" style="margin:0 0 8px;"><?php esc_html_e( 'The Scan button reads the article texts at once and the published pages afterwards, in the background — you can keep working. The numbers update when the last page is read.', 'clusterlane' ); ?></p>
+                                <?php if ( ! empty( $page_log['at'] ) ): ?>
+                                <p class="description clusterlane-rendered-log<?php echo ! empty( $page_log['failed'] ) ? ' clusterlane-rendered-log--bad' : ''; ?>"><?php
+                                    $ok = (int) ( $page_log['ok'] ?? 0 ); $bad = (int) ( $page_log['failed'] ?? 0 );
+                                    if ( $bad ) {
+                                        /* translators: 1: pages read, 2: pages that could not be read, 3: reason */
+                                        echo esc_html( sprintf( __( 'Last scan: %1$d published pages read, %2$d could not be read — %3$s A firewall, a security plugin or Cloudflare may stop the site from reading its own pages; those articles keep their text links.', 'clusterlane' ), $ok, $bad, rtrim( (string) ( $page_log['example']['message'] ?? '' ), '.' ) . '.' ) );
+                                        if ( ! empty( $page_log['example']['url'] ) ) echo ' <a href="' . esc_url( $page_log['example']['url'] ) . '" target="_blank" rel="noopener">' . esc_html__( 'Example page', 'clusterlane' ) . '</a>';
+                                    } else {
+                                        /* translators: %d: pages read */
+                                        echo esc_html( sprintf( _n( 'Last scan: %d published page read.', 'Last scan: %d published pages read.', $ok, 'clusterlane' ), $ok ) );
+                                    }
+                                ?></p>
+                                <?php endif; ?>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><label for="clusterlane_title_truncation"><?php esc_html_e( 'Title Display', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <select name="clusterlane[title_truncation]" id="clusterlane_title_truncation" class="clusterlane-settings-select"
+                                        onchange="document.getElementById('clusterlane-title-trunc-custom-row').style.display=this.value==='custom'?'':'none'">
+                                    <option value="none"   <?php selected( $title_truncation, 'none' ); ?>><?php esc_html_e( 'Full title (no change)', 'clusterlane' ); ?></option>
+                                    <option value="colon"  <?php selected( $title_truncation, 'colon' ); ?>><?php esc_html_e( 'Remove subtitle after colon  ( : )', 'clusterlane' ); ?></option>
+                                    <option value="dash"   <?php selected( $title_truncation, 'dash' ); ?>><?php esc_html_e( 'Remove subtitle after dash  ( – )', 'clusterlane' ); ?></option>
+                                    <option value="pipe"   <?php selected( $title_truncation, 'pipe' ); ?>><?php esc_html_e( 'Remove subtitle after pipe  ( | )', 'clusterlane' ); ?></option>
+                                    <option value="custom" <?php selected( $title_truncation, 'custom' ); ?>><?php esc_html_e( 'Custom regex pattern', 'clusterlane' ); ?></option>
+                                </select>
+                                <span class="description"><?php esc_html_e( 'Applies only inside Clusterlane views — article titles in WordPress are never changed.', 'clusterlane' ); ?></span>
+                                <?php $samples = $this->title_samples( $post_types ); ?>
+                                <p class="clusterlane-title-preview" id="clusterlane-title-preview" aria-live="polite"
+                                   data-samples="<?php echo esc_attr( wp_json_encode( $samples['titles'] ) ); ?>"
+                                   data-total="<?php echo esc_attr( (string) $samples['total'] ); ?>"
+                                   data-l-example="<?php esc_attr_e( 'Example:', 'clusterlane' ); ?>"
+                                   data-l-yours="<?php esc_attr_e( 'From your site:', 'clusterlane' ); ?>"
+                                   data-l-same="<?php esc_attr_e( 'Titles are shown exactly as written.', 'clusterlane' ); ?>"
+                                   data-l-count="<?php /* translators: 1: shortened titles, 2: all articles */ esc_attr_e( '%1$d of your %2$d articles are shortened.', 'clusterlane' ); ?>"
+                                   data-l-count-one="<?php /* translators: %d: all articles */ esc_attr_e( '1 of your %d articles is shortened.', 'clusterlane' ); ?>"
+                                   data-l-nomatch="<?php esc_attr_e( 'The pattern matches none of your recent titles.', 'clusterlane' ); ?>"
+                                   data-l-none="<?php esc_attr_e( 'None of your article titles has this separator, so nothing changes.', 'clusterlane' ); ?>"
+                                   data-l-bad="<?php esc_attr_e( 'Write the pattern as /pattern/flags to see an example.', 'clusterlane' ); ?>"></p>
+                                <div id="clusterlane-title-trunc-custom-row" style="margin-top:8px;<?php echo $title_truncation !== 'custom' ? 'display:none;' : ''; ?>">
+                                    <input type="text" name="clusterlane[title_truncation_custom]" id="clusterlane_title_trunc_custom"
+                                           value="<?php echo esc_attr( $title_trunc_custom ); ?>"
+                                           placeholder="<?php esc_attr_e( 'e.g.  /\s*[:\-–|].+$/', 'clusterlane' ); ?>"
+                                           class="regular-text">
+                                    <span class="description"><?php esc_html_e( 'JS regex with delimiter slashes, e.g. /pattern/i — matched portion is removed.', 'clusterlane' ); ?></span>
+                                </div>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><?php esc_html_e( 'Article thumbnails', 'clusterlane' ); ?></th>
+                            <td>
+                                <label>
+                                    <input type="checkbox" name="clusterlane[show_thumbnails]" value="1" <?php checked( (bool) $settings->get( 'show_thumbnails', true ) ); ?>>
+                                    <?php esc_html_e( 'Show small featured images next to articles in lists, search and graph tooltips', 'clusterlane' ); ?>
+                                </label>
+                                <p class="description"><?php esc_html_e( 'Articles without a featured image get a letter tile. Turn off if few of your articles have featured images.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><label for="clusterlane_gallery_shortcodes"><?php esc_html_e( 'Extra gallery shortcodes', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <textarea id="clusterlane_gallery_shortcodes" name="clusterlane[gallery_shortcodes]"
+                                          rows="3"
+                                          style="width:100%;max-width:480px;font-family:monospace;font-size:13px;"><?php echo esc_textarea( $settings->get( 'gallery_shortcodes', '' ) ); ?></textarea>
+                                <p class="description"><?php esc_html_e( 'One shortcode name per line (without brackets), e.g. custom_envira. These are counted as images during scan, in addition to the built-in list.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+                    </table>
+                    </div>
+                </section>
+                <!-- SEO TITLE & DESCRIPTION (SeoMeta) -->
+                <?php
+                $seo_src   = \Clusterlane\Core\SeoMeta::source();
+                $seo_check = $this->seo_check( $settings );
+                $seo_mode  = (string) $settings->get( 'seo_source', 'auto' );
+                ?>
+                <section class="clusterlane-settings-section clusterlane-set-card" id="clusterlane-set-seo">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'search' ); ?><?php esc_html_e( 'SEO title & description', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <p class="clusterlane-set-note"><?php clusterlane_glyph_e( 'bulb' ); ?><?php esc_html_e( 'Used by the search-result preview in Article Inspector, the SEO score and the "Missing or weak meta" filter. Clusterlane only reads these fields.', 'clusterlane' ); ?></p>
+                    <table class="form-table" role="presentation">
+                        <tr>
+                            <th><?php esc_html_e( 'Source', 'clusterlane' ); ?></th>
+                            <td>
+                                <label style="display:block;margin-bottom:4px;">
+                                    <input type="radio" name="clusterlane[seo_source]" value="auto" <?php checked( $seo_mode !== 'custom' ); ?>>
+                                    <?php esc_html_e( 'Auto-detect SEO plugin', 'clusterlane' ); ?>
+                                    <span class="description">(Yoast, Rank Math, SEOPress, All in One SEO, The SEO Framework)</span>
+                                </label>
+                                <label style="display:block;">
+                                    <input type="radio" name="clusterlane[seo_source]" value="custom" <?php checked( $seo_mode, 'custom' ); ?>>
+                                    <?php esc_html_e( 'Custom fields (post meta or ACF)', 'clusterlane' ); ?>
+                                </label>
+                                <p class="description clusterlane-seo-detected">
+                                    <?php
+                                    if ( $seo_src['detected'] ) {
+                                        /* translators: %s: SEO plugin name */
+                                        printf( esc_html__( 'In use: %s.', 'clusterlane' ), '<strong>' . esc_html( $seo_src['label'] ) . '</strong>' );
+                                    } elseif ( $seo_mode === 'custom' ) {
+                                        esc_html_e( 'In use: the custom fields below.', 'clusterlane' );
+                                    } else {
+                                        esc_html_e( 'No SEO plugin detected — using the custom fields below.', 'clusterlane' );
+                                    }
+                                    ?>
+                                </p>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><label for="clusterlane_seo_title_field"><?php esc_html_e( 'SEO title field', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="text" id="clusterlane_seo_title_field" name="clusterlane[seo_title_field]" class="regular-text code" value="<?php echo esc_attr( (string) $settings->get( 'seo_title_field', 'seo_title' ) ); ?>" placeholder="seo_title" spellcheck="false">
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><label for="clusterlane_seo_description_field"><?php esc_html_e( 'Meta description field', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="text" id="clusterlane_seo_description_field" name="clusterlane[seo_description_field]" class="regular-text code" value="<?php echo esc_attr( (string) $settings->get( 'seo_description_field', 'seo_description' ) ); ?>" placeholder="seo_description" spellcheck="false">
+                                <p class="description"><?php esc_html_e( 'The meta key, or the ACF field name (not the field label). Used when no SEO plugin is detected, or always with "Custom fields".', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+                        <tr>
+                            <th><?php esc_html_e( 'When empty', 'clusterlane' ); ?></th>
+                            <td>
+                                <label>
+                                    <input type="checkbox" name="clusterlane[seo_fallback]" value="1" <?php checked( (bool) $settings->get( 'seo_fallback', true ) ); ?>>
+                                    <?php esc_html_e( 'Fall back to the post title and the excerpt', 'clusterlane' ); ?>
+                                </label>
+                                <p class="description"><?php esc_html_e( 'Turn off to see an empty description as "missing", even when the post has an excerpt.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+                        <?php if ( $seo_check ) : ?>
+                        <tr>
+                            <th><?php esc_html_e( 'Check', 'clusterlane' ); ?></th>
+                            <td>
+                                <div class="clusterlane-seo-check">
+                                    <?php if ( $seo_check['sample'] ) : $smp = $seo_check['sample']; ?>
+                                    <p>
+                                        <span class="clusterlane-seo-check-icon<?php echo $smp['description'] !== '' ? '' : ' is-warn'; ?>"><?php clusterlane_glyph_e( $smp['description'] !== '' ? 'check' : 'alert' ); ?></span>
+                                        <?php
+                                        /* translators: %s: post title */
+                                        printf( esc_html__( 'Latest article, "%s":', 'clusterlane' ), esc_html( wp_html_excerpt( $smp['post_title'], 60, '…' ) ) );
+                                        echo ' ';
+                                        if ( $smp['description'] !== '' ) {
+                                            /* translators: 1: number of characters, 2: where it came from */
+                                            printf( esc_html__( 'description %1$d characters (%2$s)', 'clusterlane' ), (int) \Clusterlane\Core\SeoMeta::len( $smp['description'] ), esc_html( $smp['description_from'] === 'excerpt' ? __( 'from the excerpt', 'clusterlane' ) : __( 'from the field', 'clusterlane' ) ) );
+                                        } else {
+                                            esc_html_e( 'no description found', 'clusterlane' );
+                                        }
+                                        echo ', ';
+                                        echo esc_html( $smp['title_from'] === 'field' ? __( 'SEO title from the field.', 'clusterlane' ) : __( 'title from the post title.', 'clusterlane' ) );
+                                        ?>
+                                    </p>
+                                    <?php endif; ?>
+                                    <p class="clusterlane-seo-check-counts">
+                                        <?php
+                                        /* translators: 1: articles with a field value, 2: total articles */
+                                        printf( esc_html__( 'Meta description field filled on %1$d of %2$d articles.', 'clusterlane' ), (int) $seo_check['filled'], (int) $seo_check['total'] );
+                                        if ( $seo_check['total'] && ! $seo_check['filled'] ) {
+                                            echo ' <strong>' . esc_html__( 'Check the field name — nothing was found.', 'clusterlane' ) . '</strong>';
+                                        }
+                                        ?>
+                                    </p>
+                                    <p class="description"><?php esc_html_e( 'Checked with the saved settings. After changing the source, run Scan to update SEO scores.', 'clusterlane' ); ?></p>
+                                </div>
+                            </td>
+                        </tr>
+                        <?php endif; ?>
+                    </table>
+                    <?php
+                    // Premium: how AI writes SEO title/description suggestions (+ write permission).
+                    if ( \Clusterlane\Core\Edition::has( 'ai' ) && has_action( 'clusterlane_settings_seo_ai_section' ) ) {
+                        do_action( 'clusterlane_settings_seo_ai_section', $settings );
+                    } else {
+                        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+                        echo wp_kses_post( \Clusterlane\Admin\ProExamples::note_html( 'ai', __( 'AI suggestions for SEO title & description', 'clusterlane' ), __( 'Three suggestions per article from its content and clusters, with your brand suffix, language and style — applied to your custom fields with one click and undo.', 'clusterlane' ) ) );
+                    }
+                    ?>
+                    </div>
+                </section>
+                <!-- TEXT MATCH SETTINGS -->
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'link' ); ?><?php esc_html_e( 'Text Match', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <table class="form-table" role="presentation">
+                        <tr>
+                            <th><label for="clusterlane_tms_stop_words"><?php esc_html_e( 'Custom stop words & patterns', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <textarea id="clusterlane_tms_stop_words" name="clusterlane[tms_stop_words]"
+                                          rows="6"
+                                          style="width:100%;max-width:480px;font-family:monospace;font-size:13px;"><?php echo esc_textarea( $settings->get( 'tms_stop_words', '' ) ); ?></textarea>
+                                <p class="description">
+                                    <?php esc_html_e( 'One entry per line, or comma-separated. Two formats are supported:', 'clusterlane' ); ?><br><br>
+                                    <strong><?php esc_html_e( 'Plain words:', 'clusterlane' ); ?></strong>
+                                    <?php esc_html_e( 'stockholm, museum, guide', 'clusterlane' ); ?><br>
+                                    <span style="color:#64748b;font-size:.9em;"><?php esc_html_e( 'A single word suppresses that word when building match keywords. A multi-word entry (e.g. "Royal Stockholm") suppresses that exact title entirely.', 'clusterlane' ); ?></span><br><br>
+                                    <strong><?php esc_html_e( 'Regex patterns:', 'clusterlane' ); ?></strong>
+                                    <code>/^guide\s+to/i</code>, <code>/museum(et|er)?/i</code><br>
+                                    <span style="color:#64748b;font-size:.9em;"><?php esc_html_e( 'Wrap in / / with optional flags (i = case-insensitive). Matches against individual words AND whole titles — useful for word inflections, prefixes or phrases you want to suppress entirely.', 'clusterlane' ); ?></span>
+                                </p>
+                            </td>
+                        </tr>
+                    </table>
+                
+                    </div>
+                </section>
+                </div>
+
+                <div class="clusterlane-set-panel" data-tab="styles" role="tabpanel" hidden>
+                <!-- LINK CARD TEMPLATES -->
+                <section class="clusterlane-settings-section clusterlane-set-card" data-collapse-key="clusterlane_settings_link_templates">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'card' ); ?><?php esc_html_e( 'Link Card Templates', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <?php
+                    // Premium: SettingsAi renders the Link Card Templates builder.
+                    if ( \Clusterlane\Core\Edition::has( 'link_templates' ) && has_action( 'clusterlane_settings_lct_section' ) ) {
+                        do_action( 'clusterlane_settings_lct_section', $settings );
+                    } else {
+                        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+                        echo wp_kses_post( \Clusterlane\Admin\ProExamples::note_html( 'link_templates', __( 'Link Card Templates', 'clusterlane' ), __( 'Design your own "See also" boxes for AI-inserted links, with a live preview.', 'clusterlane' ) ) );
+                    }
+                    ?>
+                    </div><!-- /clusterlane-section-body link-card-templates -->
+                </section>
+
+                <!-- LINK TO THE PILLAR (0.98.134) -->
+                <section class="clusterlane-settings-section clusterlane-set-card" id="clusterlane-set-pillar-box">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'link' ); ?><?php esc_html_e( 'Link to the pillar', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <?php
+                    if ( \Clusterlane\Core\Edition::has( 'pillar_box' ) && has_action( 'clusterlane_settings_pillar_box_section' ) ) {
+                        do_action( 'clusterlane_settings_pillar_box_section', $settings );
+                    } else {
+                        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-pro-examples.php';
+                        echo wp_kses_post( \Clusterlane\Admin\ProExamples::note_html( 'pillar_box', __( 'Link to the pillar', 'clusterlane' ), __( 'A short link to the cluster\'s pillar at the end of every article, added when the article is shown. Choose another pillar and every link follows.', 'clusterlane' ) ) );
+                    }
+                    ?>
+                    </div>
+                </section>
+                </div>
+
+                <div class="clusterlane-set-panel" data-tab="advanced" role="tabpanel" hidden>
+                    <p class="clusterlane-set-intro"><?php esc_html_e( 'You rarely need to change these. They tune how Clusterlane measures and draws your clusters, and the instructions it gives the AI.', 'clusterlane' ); ?></p>
+                <!-- CLUSTER THRESHOLDS -->
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head">
+                        <h2><?php clusterlane_glyph_e( 'scale' ); ?><?php esc_html_e( 'Cluster Thresholds', 'clusterlane' ); ?></h2>
+                    </div>
+                    <div class="clusterlane-section-body">
+                        <button type="button" id="clusterlane-reset-thresholds" class="clusterlane-action-btn clusterlane-action-btn--tertiary" style="min-width:0;"
+                                style="margin-bottom:12px;"
+                                data-defaults='<?php echo esc_attr( wp_json_encode( $this->default_thresholds() ) ); ?>'>
+                            <?php clusterlane_glyph_e( 'refresh' ); ?><?php esc_html_e( 'Reset to defaults', 'clusterlane' ); ?>
+                        </button>
+                    <p class="description" style="margin-bottom:16px;">
+                        <?php esc_html_e( 'An archetype describes the shape of a cluster’s internal links. Each cluster gets the first archetype whose conditions it meets, from the top down. Clusters are recalculated automatically when you save changes here.', 'clusterlane' ); ?>
+                    </p>
+                    <table class="form-table" role="presentation">
+
+                        <tr>
+                            <th><?php esc_html_e( 'Balanced Mesh', 'clusterlane' ); ?><?php clusterlane_tip( 'archetype' ); ?></th>
+                            <td>
+                                <?php esc_html_e( 'Entropy', 'clusterlane' ); ?><?php clusterlane_tip( 'entropy' ); ?> &gt;
+                                <input type="number" name="clusterlane[thresholds][balanced_mesh_entropy]"
+                                       value="<?php echo esc_attr( $t['balanced_mesh_entropy'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                &nbsp;<?php esc_html_e( 'AND Gini', 'clusterlane' ); ?><?php clusterlane_tip( 'gini' ); ?> &lt;
+                                <input type="number" name="clusterlane[thresholds][balanced_mesh_gini]"
+                                       value="<?php echo esc_attr( $t['balanced_mesh_gini'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                <p class="description"><?php esc_html_e( 'Evenly distributed links across many topics.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><?php esc_html_e( 'Authority Pyramid', 'clusterlane' ); ?></th>
+                            <td>
+                                <?php esc_html_e( 'Entropy >', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][authority_pyramid_entropy]"
+                                       value="<?php echo esc_attr( $t['authority_pyramid_entropy'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                &nbsp;<?php esc_html_e( 'AND Gini >', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][authority_pyramid_gini]"
+                                       value="<?php echo esc_attr( $t['authority_pyramid_gini'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                <p class="description"><?php esc_html_e( 'A few hub articles dominate inbound links.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><?php esc_html_e( 'Developed Cluster', 'clusterlane' ); ?></th>
+                            <td>
+                                <?php esc_html_e( 'Min posts:', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][developed_min_posts]"
+                                       value="<?php echo esc_attr( $t['developed_min_posts'] ); ?>" min="1" max="50" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                &nbsp;<?php esc_html_e( 'Min avg score:', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][developed_min_score]"
+                                       value="<?php echo esc_attr( $t['developed_min_score'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                &nbsp;<?php esc_html_e( 'Max orphan %:', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][developed_max_orphan]"
+                                       value="<?php echo esc_attr( $t['developed_max_orphan'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                <p class="description"><?php esc_html_e( 'Large mature clusters that don\'t fit other archetypes.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><?php esc_html_e( 'Emerging Cluster', 'clusterlane' ); ?></th>
+                            <td>
+                                <?php esc_html_e( 'Fewer than', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][emerging_max_posts]"
+                                       value="<?php echo esc_attr( $t['emerging_max_posts'] ); ?>" min="2" max="20" class="clusterlane-settings-number clusterlane-settings-number--sm">
+                                <?php esc_html_e( 'posts', 'clusterlane' ); ?>
+                                <p class="description"><?php esc_html_e( 'Small clusters still being built out.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><?php esc_html_e( 'Health Colors', 'clusterlane' ); ?></th>
+                            <td>
+                                <span style="color:#1a7f37;">&#9679;</span> <?php esc_html_e( 'Green &ge;', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][health_green]"
+                                       value="<?php echo esc_attr( $t['health_green'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">%
+                                &nbsp;&nbsp;
+                                <span style="color:#c05c00;">&#9679;</span> <?php esc_html_e( 'Orange &ge;', 'clusterlane' ); ?>
+                                <input type="number" name="clusterlane[thresholds][health_orange]"
+                                       value="<?php echo esc_attr( $t['health_orange'] ); ?>" min="0" max="100" class="clusterlane-settings-number clusterlane-settings-number--sm">%
+                                <p class="description"><?php esc_html_e( 'Below orange = red.', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                    </table>
+
+                    </div><!-- /clusterlane-section-body cluster-thresholds -->
+                </section>
+                <?php \Clusterlane\Admin\Inline::start(); ?>
+                (function() {
+                    // Prevent button clicks inside <summary> from toggling the <details>.
+                    document.querySelectorAll('.clusterlane-settings-summary button').forEach(function(btn) {
+                        btn.addEventListener('click', function(e) { e.stopPropagation(); });
+                    });
+
+                    var resetBtn = document.getElementById("clusterlane-reset-thresholds");
+                    if (!resetBtn) return;
+                    resetBtn.addEventListener("click", function() {
+                        var defaults = JSON.parse(resetBtn.dataset.defaults || "{}");
+                        Object.keys(defaults).forEach(function(key) {
+                            var field = document.querySelector('input[name="clusterlane[thresholds][' + key + ']"]');
+                            if (field) field.value = defaults[key];
+                        });
+                        resetBtn.closest('form').dispatchEvent(new Event('change', { bubbles: true }));   // show the save bar
+                    });
+                })();
+                <?php \Clusterlane\Admin\Inline::script(); ?>
+                <section class="clusterlane-settings-section clusterlane-set-card" id="clusterlane-set-prompts" hidden>
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'sparkle' ); ?><?php esc_html_e( 'AI prompts', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body" id="clusterlane-set-prompts-slot"></div>
+                </section>
+                <!-- GRAPH DISPLAY SETTINGS -->
+                <?php
+                $gd = wp_parse_args( $settings->get( 'graph_display', [] ), [
+                    'avoid_overlap'   => 1.1,
+                    'spring_length'   => 180,
+                    'central_gravity' => 0.12,
+                    'repulsion'       => 320,
+                    'iterations'      => 220,
+                    'graph_height'    => 420,
+                ] );
+                ?>
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'nodes' ); ?><?php esc_html_e( 'Graph Display', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    
+                    <p class="description" style="margin-bottom:16px;"><?php esc_html_e( 'Controls how the cluster graph lays out nodes. Changes take effect immediately when you next open a cluster.', 'clusterlane' ); ?></p>
+                    <table class="form-table" role="presentation">
+
+                        <tr>
+                            <th><label for="clusterlane_gd_avoid_overlap"><?php esc_html_e( 'Node spacing', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_avoid_overlap" name="clusterlane[graph_display][avoid_overlap]"
+                                       min="0.5" max="3" step="0.1"
+                                       value="<?php echo esc_attr( $gd['avoid_overlap'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value">
+                                <output><?php echo esc_html( $gd['avoid_overlap'] ); ?></output>
+                                <p class="description"><?php esc_html_e( 'How much nodes push each other apart. Higher = less overlap, more spread out. (default: 1.1)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><label for="clusterlane_gd_spring_length"><?php esc_html_e( 'Edge length', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_spring_length" name="clusterlane[graph_display][spring_length]"
+                                       min="80" max="400" step="10"
+                                       value="<?php echo esc_attr( $gd['spring_length'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value">
+                                <output><?php echo esc_html( $gd['spring_length'] ); ?></output>
+                                <p class="description"><?php esc_html_e( 'Preferred length of edges between connected nodes. Higher = more space between linked nodes. (default: 180)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><label for="clusterlane_gd_central_gravity"><?php esc_html_e( 'Central pull', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_central_gravity" name="clusterlane[graph_display][central_gravity]"
+                                       min="0" max="0.5" step="0.01"
+                                       value="<?php echo esc_attr( $gd['central_gravity'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value">
+                                <output><?php echo esc_html( $gd['central_gravity'] ); ?></output>
+                                <p class="description"><?php esc_html_e( 'How strongly nodes are pulled toward the center. Lower = airier but more spread out. (default: 0.12)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><label for="clusterlane_gd_repulsion"><?php esc_html_e( 'Node repulsion', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_repulsion" name="clusterlane[graph_display][repulsion]"
+                                       min="50" max="800" step="10"
+                                       value="<?php echo esc_attr( $gd['repulsion'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value">
+                                <output><?php echo esc_html( $gd['repulsion'] ); ?></output>
+                                <p class="description"><?php esc_html_e( 'How strongly unconnected nodes repel each other. Higher = more separation. (default: 320)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><label for="clusterlane_gd_iterations"><?php esc_html_e( 'Layout iterations', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_iterations" name="clusterlane[graph_display][iterations]"
+                                       min="50" max="500" step="10"
+                                       value="<?php echo esc_attr( $gd['iterations'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value">
+                                <output><?php echo esc_html( $gd['iterations'] ); ?></output>
+                                <p class="description"><?php esc_html_e( 'How many physics steps run before the graph locks. More = better layout but slower. (default: 220)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                        <tr>
+                            <th><label for="clusterlane_gd_graph_height"><?php esc_html_e( 'Graph height (px)', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="range" id="clusterlane_gd_graph_height" name="clusterlane[graph_display][graph_height]"
+                                       min="300" max="800" step="20"
+                                       value="<?php echo esc_attr( $gd['graph_height'] ); ?>"
+                                       oninput="this.nextElementSibling.textContent=this.value+'px'">
+                                <output><?php echo esc_html( $gd['graph_height'] ); ?>px</output>
+                                <p class="description"><?php esc_html_e( 'Height of the graph canvas in the cluster modal. Useful for clusters with many nodes. (default: 420)', 'clusterlane' ); ?></p>
+                            </td>
+                        </tr>
+
+                    </table>
+                
+                    </div>
+                </section>
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'bolt' ); ?><?php esc_html_e( 'Scanning performance', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <table class="form-table" role="presentation">
+                        <tr>
+                            <th><label for="clusterlane_batch_size"><?php esc_html_e( 'Scan Batch Size', 'clusterlane' ); ?></label></th>
+                            <td>
+                                <input type="number" name="clusterlane[batch_size]" id="clusterlane_batch_size"
+                                       value="<?php echo esc_attr( (string) $batch_size ); ?>" min="5" max="50" step="5" class="clusterlane-settings-number">
+                                <span class="description"><?php esc_html_e( 'Posts per API call (5–50). Higher = faster but more memory.', 'clusterlane' ); ?></span>
+                            </td>
+                        </tr>
+                    </table>
+                    </div>
+                </section>
+                <?php
+                // Premium: AI cache — see and clear saved AI results (admins).
+                if ( \Clusterlane\Core\Edition::has( 'ai' ) ) do_action( 'clusterlane_settings_ai_cache_section', $settings );
+                ?>
+                <!-- Recalculate (0.98.186): Sync + Metrics without reading the articles — the
+                     bar has only Scan now; this covers categories or tags changed in WordPress. -->
+                <section class="clusterlane-settings-section clusterlane-set-card">
+                    <div class="clusterlane-set-card-head"><h2><?php clusterlane_glyph_e( 'refresh' ); ?><?php esc_html_e( 'Recalculate clusters', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <p><?php esc_html_e( 'Rebuilds the clusters from your categories, tags and Cluster Admin choices and recomputes health, roles and archetypes — without reading the articles again. Useful after changing many categories or tags in WordPress. Scan does the same and also reads the articles, so you rarely need this.', 'clusterlane' ); ?></p>
+                    <button type="button" id="clusterlane-recalc" class="clusterlane-action-btn clusterlane-action-btn--secondary" style="min-width:0;">
+                        <?php esc_html_e( 'Recalculate clusters', 'clusterlane' ); ?>
+                    </button>
+                    <span id="clusterlane-recalc-status" style="margin-left:12px;font-size:.85rem;"></span>
+                    </div>
+                </section>
+                <?php \Clusterlane\Admin\Inline::start(); ?>
+                (function() {
+                    var btn = document.getElementById("clusterlane-recalc"), st = document.getElementById("clusterlane-recalc-status");
+                    if (!btn) return;
+                    function post(path) {
+                        return fetch(clusterlaneData.restUrl + path, { method: "POST", credentials: "same-origin", headers: { "X-WP-Nonce": clusterlaneData.nonce, "Content-Type": "application/json" } })
+                            .then(function(r) { return r.json().then(function(d) { if (!r.ok) throw new Error(d && d.message || ("HTTP " + r.status)); return d; }); });
+                    }
+                    btn.addEventListener("click", function() {
+                        btn.disabled = true; st.style.color = ""; st.textContent = "Recalculating…";
+                        var created = 0;
+                        post("clusters/sync").then(function(d) { created = parseInt(d && d.created, 10) || 0; return post("clusters/compute"); })
+                            .then(function(d) {
+                                var n = parseInt(d && d.computed, 10) || 0;
+                                st.style.color = "#1a7f37";
+                                st.textContent = "Done — " + n + " cluster" + (n === 1 ? "" : "s") + " updated" + (created ? ", " + created + " new" : "") + ".";
+                            })
+                            .catch(function(e) { st.style.color = "#b32d2e"; st.textContent = "Failed: " + (e && e.message ? e.message : "unknown error"); })
+                            .then(function() { btn.disabled = false; });
+                    });
+                })();
+                <?php \Clusterlane\Admin\Inline::script(); ?>
+                <!-- Danger Zone -->
+                <section class="clusterlane-settings-section clusterlane-set-card" style="border-color:#b32d2e;margin-top:32px;">
+                    <div class="clusterlane-set-card-head"><h2 style="color:#b32d2e;"><?php clusterlane_glyph_e( 'alert' ); ?><?php esc_html_e( 'Danger Zone', 'clusterlane' ); ?></h2></div>
+                    <div class="clusterlane-section-body">
+                    <p><?php esc_html_e( 'Delete all Clusterlane data — scan results, clusters and metrics. The plugin stays active but all data is reset. Cannot be undone. The history (daily snapshots and the activity log) and the original SEO titles and descriptions are kept; they are removed only when the plugin is deleted.', 'clusterlane' ); ?></p>
+                    <button type="button" id="clusterlane-reset-all" class="clusterlane-action-btn clusterlane-action-btn--danger" style="min-width:0;">
+                        <?php esc_html_e( 'Delete All Data', 'clusterlane' ); ?>
+                    </button>
+                    <span id="clusterlane-reset-status" style="margin-left:12px;font-size:.85rem;"></span>
+                
+                    </div>
+                </section>
+                <?php \Clusterlane\Admin\Inline::start(); ?>
+                (function() {
+                    var btn = document.getElementById("clusterlane-reset-all");
+                    var status = document.getElementById("clusterlane-reset-status");
+                    if (btn) btn.addEventListener("click", function() {
+                        if (!confirm("Delete ALL Clusterlane data? This cannot be undone.")) return;
+                        btn.disabled = true;
+                        btn.textContent = "Deleting...";
+                        fetch(clusterlaneData.restUrl + "reset", {
+                            method: "POST",
+                            headers: {"X-WP-Nonce": clusterlaneData.nonce, "Content-Type": "application/json"},
+                            credentials: "same-origin"
+                        }).then(function(r) { return r.json(); }).then(function() {
+                            status.textContent = "All data deleted.";
+                            status.style.color = "#1a7f37";
+                            btn.textContent = "Delete All Data";
+                            btn.disabled = false;
+                        }).catch(function() {
+                            status.textContent = "Error.";
+                            status.style.color = "#b32d2e";
+                            btn.disabled = false;
+                            btn.textContent = "Delete All Data";
+                        });
+                    });
+                })();
+                <?php \Clusterlane\Admin\Inline::script(); ?>
+                </div>
+
+                <!-- Appears only when something changed -->
+                <div class="clusterlane-ca-savebar clusterlane-set-savebar" id="clusterlane-set-savebar" hidden>
+                    <span><strong id="clusterlane-set-changes"></strong> <span class="clusterlane-tm-note" id="clusterlane-set-where"></span></span>
+                    <span class="clusterlane-ca-savebar-actions">
+                        <button type="button" class="clusterlane-tm-btn" id="clusterlane-set-discard"><?php esc_html_e( 'Discard', 'clusterlane' ); ?></button>
+                        <button type="submit" name="clusterlane_save_settings" class="clusterlane-tm-btn clusterlane-tm-btn--apply"><?php esc_html_e( 'Save settings', 'clusterlane' ); ?></button>
+                    </span>
+                </div>
+            </form>
+        </div>
+        <?php \Clusterlane\Admin\Inline::start(); ?>
+        (function() {
+            // ── Collapse state (localStorage) ─────────────────────────
+            document.querySelectorAll('details.clusterlane-settings-section[data-collapse-key]').forEach(function(d) {
+                var k = d.dataset.collapseKey;
+                try { var v = localStorage.getItem(k); if (v === '0') d.open = false; if (v === '1') d.open = true; } catch(e) {}
+                d.addEventListener('toggle', function() {
+                    try { localStorage.setItem(k, d.open ? '1' : '0'); } catch(e) {}
+                });
+            });
+
+            // (AI provider/model sync and Test connection: premium, clusterlane_settings_footer.)
+        })();
+        <?php \Clusterlane\Admin\Inline::script(); ?>
+        <?php do_action( 'clusterlane_settings_footer', $settings ); // premium scripts ?>
+        <?php
+    }
+
+    /**
+     * Titles for the Title Display preview (0.98.130): the published articles
+     * of the scanned post types whose title has a separator, newest first,
+     * then the 300 newest of the rest (for a custom pattern), and how many
+     * articles there are in all. Only titles, nothing else.
+     * @return array{titles:string[], total:int}
+     */
+    private function title_samples( array $post_types ): array {
+        global $wpdb;
+        $post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) ) ?: [ 'post' ];
+        $total  = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ")", $post_types ) );
+        $titles = $wpdb->get_col( $wpdb->prepare(
+            "SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ")
+             AND ( post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s OR post_title LIKE %s )
+             ORDER BY post_date DESC LIMIT 3000",
+            array_merge( $post_types, [ '%:%', '%|%', '%' . "\u{2013}" . '%', '%' . "\u{2014}" . '%', '% - %' ] )
+        ) );
+        // A few recent titles without a separator too, for a custom pattern.
+        $recent = $wpdb->get_col( $wpdb->prepare(
+            "SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ") ORDER BY post_date DESC LIMIT 300",
+            $post_types
+        ) );
+        $titles = array_unique( array_merge( (array) $titles, (array) $recent ) );
+        $titles = array_map( static fn( $t ) => html_entity_decode( wp_strip_all_tags( (string) $t ), ENT_QUOTES, 'UTF-8' ), (array) $titles );
+        return [ 'titles' => array_values( $titles ), 'total' => $total ];
+    }
+
+    /**
+     * The posted settings form (clusterlane[...]), unslashed once and sanitized
+     * field by field (1.3.0). Only fields that were posted are returned, so the
+     * save code can still tell "not on the page" from "turned off". Everything
+     * after this — the core fields below and the premium sections through the
+     * clusterlane_settings_save / clusterlane_settings_saved hooks — sees only
+     * this sanitized copy, never $_POST.
+     *
+     * @return array<string,mixed>
+     */
+    public static function posted(): array {
+        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- the nonce is verified by Admin::handle_settings_save(); every field is sanitized below
+        $in = isset( $_POST['clusterlane'] ) && is_array( $_POST['clusterlane'] ) ? wp_unslash( $_POST['clusterlane'] ) : [];
+
+        $key   = static fn( $v ): string => is_scalar( $v ) ? sanitize_key( (string) $v ) : '';
+        $text  = static fn( $v ): string => is_scalar( $v ) ? sanitize_text_field( (string) $v ) : '';
+        $long  = static fn( $v ): string => is_scalar( $v ) ? \Clusterlane\Core\Settings::clean_text( (string) $v, 20000 ) : ''; // prompts, stop words: plain text kept as typed (control characters removed)
+        $keys  = static fn( $v ): array => array_values( array_filter( array_map( $key, (array) $v ) ) );
+
+        $schema = [
+            // Flags (checkboxes and "this section was on the page" markers).
+            'rendered_cached' => 'flag', 'toolbar_present' => 'flag', 'toolbar' => 'flag', 'show_thumbnails' => 'flag',
+            'seo_fallback' => 'flag', 'seo_ai_write' => 'flag', 'ai_roles_present' => 'flag', 'ai_api_key_remove' => 'flag',
+            'pillar_box_present' => 'flag', 'pillar_box' => 'flag',
+            // Choices.
+            'title_truncation' => 'key', 'seo_source' => 'key', 'seo_ai_suffix_mode' => 'key', 'ai_provider' => 'key',
+            'ai_tone' => 'key', 'pillar_box_style' => 'key',
+            // Single-line text.
+            'title_truncation_custom' => 'text', 'seo_title_field' => 'text', 'seo_description_field' => 'text',
+            'seo_ai_suffix' => 'text', 'seo_ai_language' => 'text', 'ai_model' => 'text', 'ai_model_analysis' => 'text',
+            'ai_api_key' => 'text', 'pillar_box_text' => 'text',
+            // Numbers.
+            'min_term_count' => 'int', 'batch_size' => 'int', 'ai_limit_users' => 'int',
+            // Multi-line text.
+            'tms_stop_words' => 'long', 'seo_ai_instructions' => 'long', 'gallery_shortcodes' => 'textarea',
+            // Lists of slugs.
+            'post_types' => 'keys', 'rendered_types' => 'keys', 'taxonomies' => 'keys', 'write_caps_present' => 'keys', 'ai_roles' => 'keys',
+        ];
+
+        $out = [];
+        foreach ( $schema as $field => $type ) {
+            if ( ! array_key_exists( $field, $in ) ) continue;
+            $v = $in[ $field ];
+            switch ( $type ) {
+                case 'flag':     $out[ $field ] = ! empty( $v ) && ! is_array( $v ); break;
+                case 'key':      $out[ $field ] = $key( $v ); break;
+                case 'text':     $out[ $field ] = trim( $text( $v ) ); break;
+                case 'int':      $out[ $field ] = is_scalar( $v ) ? (int) $v : 0; break;
+                case 'long':     $out[ $field ] = $long( $v ); break;
+                case 'textarea': $out[ $field ] = is_scalar( $v ) ? sanitize_textarea_field( (string) $v ) : ''; break;
+                case 'keys':     $out[ $field ] = $keys( $v ); break;
+            }
+        }
+
+        // role => [ capability, … ]
+        if ( isset( $in['write_caps'] ) ) {
+            $out['write_caps'] = [];
+            foreach ( (array) $in['write_caps'] as $role => $caps ) $out['write_caps'][ $key( $role ) ] = $keys( $caps );
+        }
+        // threshold => whole number; graph setting => number.
+        foreach ( [ 'thresholds' => 'int', 'graph_display' => 'float' ] as $field => $type ) {
+            if ( ! isset( $in[ $field ] ) ) continue;
+            $out[ $field ] = [];
+            foreach ( (array) $in[ $field ] as $k => $v ) {
+                if ( is_scalar( $v ) ) $out[ $field ][ $key( $k ) ] = 'int' === $type ? (int) $v : (float) $v;
+            }
+        }
+        // AI prompts (premium): prompt key => text.
+        if ( isset( $in['prompts'] ) ) {
+            $out['prompts'] = [];
+            foreach ( (array) $in['prompts'] as $k => $v ) $out['prompts'][ $key( $k ) ] = $long( $v );
+        }
+        // Link Card Templates (premium): markup meant for posts, so it is
+        // filtered like post content (wp_kses_post), not stripped of HTML.
+        if ( isset( $in['link_templates'] ) ) {
+            $out['link_templates'] = [];
+            foreach ( (array) $in['link_templates'] as $row ) {
+                if ( ! is_array( $row ) ) continue;
+                $out['link_templates'][] = [
+                    'id'   => $key( $row['id'] ?? '' ),
+                    'name' => $text( $row['name'] ?? '' ),
+                    'html' => is_scalar( $row['html'] ?? null ) ? wp_kses_post( (string) $row['html'] ) : '',
+                ];
+            }
+        }
+        return $out;
+    }
+
+    private function save( \Clusterlane\Core\Settings $settings ): void {
+        $raw  = self::posted();
+        $data = $settings->all();
+
+        $data['post_types']     = array_map( fn( $v ) => sanitize_key( $v ), (array) ( $raw['post_types']  ?? ['post'] ) );
+        // Published pages are read only for scanned post types (0.98.177).
+        $data['rendered_types'] = array_values( array_intersect( array_map( fn( $v ) => sanitize_key( $v ), (array) ( $raw['rendered_types'] ?? [] ) ), $data['post_types'] ) );
+        $data['rendered_cached'] = ! empty( $raw['rendered_cached'] ); // 0.98.178
+        // Taxonomies and minimum term count are managed in Cluster Admin now;
+        // keep the stored values unless a (legacy) form still posts them.
+        if ( isset( $raw['taxonomies'] ) )     $data['taxonomies']     = array_map( fn( $v ) => sanitize_key( $v ), (array) $raw['taxonomies'] );
+        if ( isset( $raw['min_term_count'] ) ) $data['min_term_count'] = min( 100, max( 1, (int) $raw['min_term_count'] ) );
+        $data['batch_size']     = min( 50, max( 5, (int) ( $raw['batch_size']      ?? 25 ) ) );
+
+        // Who can change articles (0.98.132): per role, only the permissions
+        // shown on the page, never administrators.
+        if ( ! empty( $raw['write_caps_present'] ) ) {
+            $present = array_intersect( array_map( 'sanitize_key', (array) $raw['write_caps_present'] ), \Clusterlane\Core\Capabilities::WRITE_CAPS );
+            $chosen  = (array) ( $raw['write_caps'] ?? [] );
+            foreach ( wp_roles()->role_objects as $slug => $role_obj ) {
+                if ( $slug === 'administrator' ) continue;
+                if ( ! $role_obj->has_cap( \Clusterlane\Core\Capabilities::USE ) && ! $role_obj->has_cap( \Clusterlane\Core\Capabilities::MANAGE ) ) continue;
+                $on = array_map( 'sanitize_key', (array) ( $chosen[ $slug ] ?? [] ) );
+                foreach ( $present as $cap ) {
+                    in_array( $cap, $on, true ) ? $role_obj->add_cap( $cap ) : $role_obj->remove_cap( $cap );
+                }
+            }
+        }
+
+        if ( ! empty( $raw['toolbar_present'] ) ) $data['toolbar'] = ! empty( $raw['toolbar'] );
+
+        $allowed_trunc = [ 'none', 'colon', 'dash', 'pipe', 'custom' ];
+        $raw_trunc     = sanitize_key( $raw['title_truncation'] ?? 'none' );
+        $data['title_truncation'] = in_array( $raw_trunc, $allowed_trunc, true ) ? $raw_trunc : 'none';
+        // Validate custom regex: must start and end with /
+        $raw_custom = sanitize_text_field( $raw['title_truncation_custom'] ?? '' );
+        $data['show_thumbnails'] = ! empty( $raw['show_thumbnails'] );
+        $data['title_truncation_custom'] = ( $data['title_truncation'] === 'custom' && preg_match( '/^\/.*\/[gimsuy]*$/', $raw_custom ) )
+            ? $raw_custom : '';
+        $raw_t      = $raw['thresholds'] ?? [];
+        $thresholds = [];
+        foreach ( $this->default_thresholds() as $key => $default ) {
+            $thresholds[ $key ] = isset( $raw_t[ $key ] ) ? (int) $raw_t[ $key ] : $default;
+        }
+        $data['thresholds'] = $thresholds;
+
+        $data['graph_display'] = $this->sanitize_graph_display( (array) ( $raw['graph_display'] ?? [] ) );
+        $data['tms_stop_words']    = \Clusterlane\Core\Settings::clean_text( \Clusterlane\Core\Settings::heal_entities( (string) ( $raw['tms_stop_words'] ?? '' ) ), 20000 ); // posted() unslashed it once — never again, or regex backslashes are lost
+        $data['gallery_shortcodes']= sanitize_textarea_field( $raw['gallery_shortcodes'] ?? '' );
+
+        // SEO title & description source (SeoMeta).
+        $data['seo_source']            = ( ( $raw['seo_source'] ?? 'auto' ) === 'custom' ) ? 'custom' : 'auto';
+        $data['seo_title_field']       = \Clusterlane\Core\SeoMeta::field_name( (string) ( $raw['seo_title_field'] ?? '' ) ) ?: 'seo_title';
+        $data['seo_description_field'] = \Clusterlane\Core\SeoMeta::field_name( (string) ( $raw['seo_description_field'] ?? '' ) ) ?: 'seo_description';
+        $data['seo_fallback']          = ! empty( $raw['seo_fallback'] );
+
+        // Premium sections (AI settings, prompts, usage limits, Link Card
+        // Templates) sanitize their own fields via this filter. Without them —
+        // the free build, or an edition without those features — the stored
+        // values are simply kept, since $data started from $settings->all().
+        $data = apply_filters( 'clusterlane_settings_save', $data, $raw, $settings ); // $raw: sanitized by posted()
+
+        $old_thresholds = (array) $settings->get( 'thresholds', [] );
+        $settings->save( $data );
+        \Clusterlane\Core\SeoMeta::reset();
+        do_action( 'clusterlane_settings_saved', $raw, $settings ); // premium: stores a new AI key (sanitized by posted())
+
+        // New thresholds → recalculate archetypes/health now, not "after Compute Metrics".
+        if ( wp_parse_args( $old_thresholds, $this->default_thresholds() ) != $data['thresholds'] ) {
+            $engine = $this->plugin->get( 'cluster_engine' );
+            if ( $engine ) { $engine->compute_all(); $this->recomputed = true; }
+        }
+    }
+
+    /** Set by save() when thresholds changed and clusters were recomputed. */
+    public bool $recomputed = false;
+
+    /**
+     * SEO field check for the Settings card: the latest article's values and
+     * how many articles have the description field filled (field only — no
+     * excerpt fallback), with the saved settings.
+     * @return array{sample:?array,filled:int,total:int}|null
+     */
+    private function seo_check( \Clusterlane\Core\Settings $settings ): ?array {
+        global $wpdb;
+        $types = array_values( array_filter( array_map( 'sanitize_key', (array) $settings->post_types() ) ) ) ?: [ 'post' ];
+
+        $latest = get_posts( \Clusterlane\Core\Scope::query_args( [ 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC' ] ) );
+        $sample = null;
+        if ( $latest ) {
+            $sample = \Clusterlane\Core\SeoMeta::get( $latest[0] ) + [ 'post_title' => get_the_title( $latest[0] ) ];
+        }
+
+        $src = \Clusterlane\Core\SeoMeta::source();
+        if ( $src['id'] === 'aioseo' ) {
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $row = $wpdb->get_row( $wpdb->prepare(
+                "SELECT COUNT(DISTINCT p.ID) AS total, COUNT(DISTINCT CASE WHEN CHAR_LENGTH(TRIM(seo.description)) > 0 THEN p.ID END) AS filled
+                 FROM {$wpdb->posts} p LEFT JOIN {$wpdb->prefix}aioseo_posts seo ON seo.post_id = p.ID
+                 WHERE " . \Clusterlane\Core\Scope::sql( 'p' ) . " AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ")",
+                $types
+            ) ); // Scope::sql() has no values
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        } elseif ( $src['desc'] ) {
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $row = $wpdb->get_row( $wpdb->prepare(
+                "SELECT COUNT(DISTINCT p.ID) AS total, COUNT(DISTINCT CASE WHEN CHAR_LENGTH(TRIM(seo.meta_value)) > 0 THEN p.ID END) AS filled
+                 FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} seo ON seo.post_id = p.ID AND seo.meta_key = %s
+                 WHERE " . \Clusterlane\Core\Scope::sql( 'p' ) . " AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ")",
+                array_merge( [ $src['desc'] ], $types )
+            ) ); // Scope::sql() has no values
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        } else {
+            return [ 'sample' => $sample, 'filled' => 0, 'total' => 0 ];
+        }
+        return [ 'sample' => $sample, 'filled' => (int) ( $row->filled ?? 0 ), 'total' => (int) ( $row->total ?? 0 ) ];
+    }
+
+    private function sanitize_graph_display( array $raw ): array {
+        $clamp = fn( $v, $min, $max ) => min( $max, max( $min, (float) $v ) );
+        return [
+            'avoid_overlap'   => $clamp( $raw['avoid_overlap']   ?? 1.1,  0.5, 3.0  ),
+            'spring_length'   => $clamp( $raw['spring_length']   ?? 180,  80,  400  ),
+            'central_gravity' => $clamp( $raw['central_gravity'] ?? 0.12, 0,   0.5  ),
+            'repulsion'       => $clamp( $raw['repulsion']       ?? 320,  50,  800  ),
+            'iterations'      => (int) $clamp( $raw['iterations'] ?? 220,  50,  500  ),
+            'graph_height'    => (int) $clamp( $raw['graph_height'] ?? 420, 300, 800  ),
+        ];
+    }
+
+    private function default_thresholds(): array {
+        return [
+            'balanced_mesh_entropy'     => 80,
+            'balanced_mesh_gini'        => 40,
+            'authority_pyramid_entropy' => 60,
+            'authority_pyramid_gini'    => 55,
+            'developed_min_posts'       => 12,
+            'developed_min_score'       => 90,
+            'developed_max_orphan'      => 10,
+            'emerging_max_posts'        => 6,
+            'health_green'              => 70,
+            'health_orange'             => 50,
+        ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-subnav.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-subnav.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-subnav.php	(revision 3751027)
@@ -0,0 +1,255 @@
+<?php
+/**
+ * Clusterlane Sub-navigation & Sticky Bar
+ *
+ * clusterlane_render_subnav()      — legacy inline nav (kept for back-compat, renders nothing visible)
+ * clusterlane_render_sticky_bar()  — full sticky top bar: subnav + article search + cluster dropdown + quick actions
+ *
+ * Loaded early in boot_admin() so it is always available when page classes render.
+ */
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+if ( ! function_exists( 'clusterlane_render_subnav' ) ) {
+    /**
+     * Legacy helper — now a no-op because the sticky bar renders the nav.
+     * Kept so any third-party code calling it doesn't fatal.
+     */
+    function clusterlane_render_subnav( string $active ): void {
+        // Intentionally empty — replaced by clusterlane_render_sticky_bar().
+    }
+}
+
+if ( ! function_exists( 'clusterlane_render_sticky_bar' ) ) {
+    function clusterlane_render_sticky_bar( string $active ): void {
+        // Same order and names as the left menu (0.98.186): the pages used most,
+        // then the tools, then admin, with Help last. 'g' starts a new group.
+        $pages = [
+            'dashboard'         => [ 'label' => __( 'Dashboard', 'clusterlane' ),         'icon' => 'dashicons-chart-area',      'page' => 'clusterlane-dashboard',         'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'main'  ],
+            'cluster-inspector' => [ 'label' => __( 'Cluster Inspector', 'clusterlane' ), 'icon' => 'dashicons-networking',      'page' => 'clusterlane-cluster-inspector', 'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'main'  ],
+            'article-inspector' => [ 'label' => __( 'Article Inspector', 'clusterlane' ), 'icon' => 'dashicons-visibility',      'page' => 'clusterlane-article-inspector', 'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'main'  ],
+            'report'            => [ 'label' => __( 'Report', 'clusterlane' ),            'icon' => 'dashicons-media-document',  'page' => 'clusterlane-report',            'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'main'  ],
+            'text-match'        => [ 'label' => __( 'Text Match', 'clusterlane' ),        'icon' => 'dashicons-search',          'page' => 'clusterlane-text-match',        'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'tools' ],
+            'link-suggest'      => [ 'label' => __( 'Link Suggestions', 'clusterlane' ),  'icon' => 'dashicons-admin-links',     'page' => 'clusterlane-link-suggest',      'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'tools' ],
+            'seo-titles'        => [ 'label' => __( 'SEO Titles', 'clusterlane' ),        'icon' => 'dashicons-editor-textcolor', 'page' => 'clusterlane-seo-titles',       'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'tools' ],
+            'cluster-settings'  => [ 'label' => __( 'Cluster Admin', 'clusterlane' ),     'icon' => 'dashicons-category',        'page' => 'clusterlane-cluster-settings',  'cap' => \Clusterlane\Core\Capabilities::MANAGE, 'g' => 'admin' ],
+            'settings'          => [ 'label' => __( 'Settings', 'clusterlane' ),          'icon' => 'dashicons-admin-settings',  'page' => 'clusterlane-settings',          'cap' => \Clusterlane\Core\Capabilities::MANAGE, 'g' => 'admin' ],
+            'help'              => [ 'label' => __( 'Help', 'clusterlane' ),              'icon' => 'dashicons-editor-help',     'page' => 'clusterlane-help',              'cap' => \Clusterlane\Core\Capabilities::USE,    'g' => 'help'  ],
+        ];
+        ?>
+        <div class="clusterlane-stickybar" id="clusterlane-stickybar" role="banner" aria-label="<?php esc_attr_e( 'Clusterlane toolbar', 'clusterlane' ); ?>">
+            <div class="clusterlane-stickybar__inner">
+
+                <!-- Zone 1: Page navigation -->
+                <nav class="clusterlane-stickybar__nav" aria-label="<?php esc_attr_e( 'Clusterlane navigation', 'clusterlane' ); ?>">
+                    <a class="clusterlane-stickybar__brand" href="<?php echo esc_url( admin_url( 'admin.php?page=clusterlane-dashboard' ) ); ?>" title="<?php echo esc_attr( sprintf( /* translators: %s: plugin name */ __( '%s dashboard', 'clusterlane' ), \Clusterlane\Core\Brand::name() ) ); ?>">
+                        <span class="clusterlane-stickybar__brand-mark"><?php \Clusterlane\Core\Brand::mark_e( 26, '#fff', \Clusterlane\Core\Brand::INK ); ?></span>
+                        <span class="screen-reader-text"><?php echo esc_html( sprintf( /* translators: %s: plugin name */ __( '%s dashboard', 'clusterlane' ), \Clusterlane\Core\Brand::name() ) ); ?></span>
+                    </a>
+                    <?php $prev_group = '';
+                    foreach ( $pages as $key => $p ):
+                        if ( ! current_user_can( $p['cap'] ) ) continue;
+                        $is_active = ( $key === $active );
+                        $url       = admin_url( 'admin.php?page=' . $p['page'] );
+                        $new_group = $prev_group !== '' && $p['g'] !== $prev_group;
+                        $prev_group = $p['g'];
+                    ?>
+                    <a href="<?php echo esc_url( $url ); ?>"
+                       class="clusterlane-stickybar__tab<?php echo $is_active ? ' clusterlane-stickybar__tab--active' : ''; ?><?php echo $new_group ? ' clusterlane-stickybar__tab--group' : ''; ?>"
+                       data-tab="<?php echo esc_attr( $key ); ?>"
+                       <?php if ( $is_active ) echo 'aria-current="page"'; ?>>
+                        <span class="dashicons <?php echo esc_attr( $p['icon'] ); ?>" aria-hidden="true"></span>
+                        <span class="clusterlane-stickybar__tab-label" data-label="<?php echo esc_attr( $p['label'] ); ?>"><?php echo esc_html( $p['label'] ); ?></span>
+                        <?php if ( class_exists( \Clusterlane\Core\Edition::class ) && ( ( $key === 'link-suggest' && ! \Clusterlane\Core\Edition::has( 'ai' ) ) || ( $key === 'report' && ! \Clusterlane\Core\Edition::has( 'report' ) ) ) ): ?>
+                            <span class="clusterlane-pro-badge clusterlane-pro-badge--tab"><?php esc_html_e( 'PRO', 'clusterlane' ); ?></span>
+                        <?php endif; ?>
+                    </a>
+                    <?php endforeach; ?>
+                    <!-- Tabs that don't fit move in here (clusterlane-sticky-bar.js, 0.98.186) -->
+                    <div class="clusterlane-stickybar__more" id="clusterlane-bar-more" hidden>
+                        <button type="button" class="clusterlane-stickybar__tab clusterlane-stickybar__more-btn" aria-expanded="false" aria-haspopup="true" aria-controls="clusterlane-bar-more-menu">
+                            <span class="dashicons dashicons-menu-alt3" aria-hidden="true"></span>
+                            <span class="clusterlane-stickybar__tab-label"><?php esc_html_e( 'More', 'clusterlane' ); ?></span>
+                        </button>
+                        <div class="clusterlane-stickybar__more-menu" id="clusterlane-bar-more-menu" hidden></div>
+                    </div>
+                </nav>
+                <?php \Clusterlane\Admin\Inline::start(); ?>
+                /* Fit the tabs before the bar is first painted (0.98.192) — done in the
+                   footer script it made the row jump on every page. The same tabs always
+                   go into More, whichever page is open; More is marked when the current
+                   page is in it. clusterlane-sticky-bar.js calls this again on resize. */
+                window.clusterlaneFitTabs = function () {
+                    var nav = document.querySelector( '#clusterlane-stickybar .clusterlane-stickybar__nav' ), more = document.getElementById( 'clusterlane-bar-more' ), menu = document.getElementById( 'clusterlane-bar-more-menu' );
+                    if ( ! nav || ! more || ! menu ) return;
+                    var btn = more.querySelector( '.clusterlane-stickybar__more-btn' );
+                    Array.prototype.slice.call( menu.querySelectorAll( '.clusterlane-stickybar__tab' ) ).forEach( function ( t ) { nav.insertBefore( t, more ); } );
+                    more.hidden = true; menu.hidden = true; if ( btn ) btn.setAttribute( 'aria-expanded', 'false' );
+                    if ( nav.scrollWidth <= nav.clientWidth + 1 ) { if ( btn ) btn.classList.remove( 'clusterlane-stickybar__tab--active-in' ); return; }
+                    more.hidden = false;
+                    var tabs = Array.prototype.slice.call( nav.querySelectorAll( ':scope > .clusterlane-stickybar__tab' ) );
+                    for ( var i = tabs.length - 1; i >= 0 && nav.scrollWidth > nav.clientWidth + 1; i-- ) menu.insertBefore( tabs[ i ], menu.firstChild );
+                    if ( btn ) btn.classList.toggle( 'clusterlane-stickybar__tab--active-in', !! menu.querySelector( '.clusterlane-stickybar__tab--active' ) );
+                };
+                window.clusterlaneFitTabs();
+                <?php \Clusterlane\Admin\Inline::here(); ?>
+
+                <!-- Zone 2: Search tools -->
+                <div class="clusterlane-stickybar__search-group">
+
+                    <!-- Article quick-search -->
+                    <div class="clusterlane-stickybar__search-wrap" id="clusterlane-bar-article-wrap">
+                        <span class="dashicons dashicons-search clusterlane-stickybar__search-icon" aria-hidden="true"></span>
+                        <input
+                            type="search"
+                            id="clusterlane-bar-article-input"
+                            class="clusterlane-stickybar__search-input"
+                            placeholder="<?php esc_attr_e( 'Inspect article…', 'clusterlane' ); ?>"
+                            autocomplete="off"
+                            aria-label="<?php esc_attr_e( 'Search articles to inspect', 'clusterlane' ); ?>"
+                        >
+                        <ul class="clusterlane-stickybar__dropdown" id="clusterlane-bar-article-results" role="listbox" hidden></ul>
+                    </div>
+
+                    <!-- Cluster quick-jump -->
+                    <div class="clusterlane-stickybar__search-wrap clusterlane-stickybar__search-wrap--cluster" id="clusterlane-bar-cluster-wrap">
+                        <span class="dashicons dashicons-networking clusterlane-stickybar__search-icon" aria-hidden="true"></span>
+                        <select
+                            id="clusterlane-bar-cluster-select"
+                            class="clusterlane-stickybar__cluster-select"
+                            aria-label="<?php esc_attr_e( 'Jump to cluster', 'clusterlane' ); ?>"
+                        >
+                            <option value=""><?php esc_html_e( 'Jump to cluster…', 'clusterlane' ); ?></option>
+                        </select>
+                    </div>
+
+                </div>
+
+                <!-- Zone 3: Quick actions -->
+                <div class="clusterlane-stickybar__actions">
+                    <button type="button" id="clusterlane-bar-scan" class="clusterlane-stickybar__action-btn clusterlane-stickybar__action-btn--primary"
+                            title="<?php esc_attr_e( 'Read every article again and update clusters, roles and health', 'clusterlane' ); ?>">
+                        <span class="dashicons dashicons-update" aria-hidden="true"></span>
+                        <span class="clusterlane-stickybar__action-label"><?php esc_html_e( 'Scan', 'clusterlane' ); ?></span>
+                    </button>
+                </div>
+
+            </div><!-- /.clusterlane-stickybar__inner -->
+
+            <!-- Status toast (shared with quick actions) -->
+            <div class="clusterlane-stickybar__toast" id="clusterlane-bar-toast" aria-live="polite" hidden></div>
+        </div><!-- /.clusterlane-stickybar -->
+        <?php
+    }
+}
+
+if ( ! function_exists( 'clusterlane_icon' ) ) {
+    /**
+     * Page icon tile — the same blue tile as the bolt mark, with a line glyph.
+     * One SVG set for every page header, so icons look identical on every OS
+     * (emoji differ between Windows, macOS and Android).
+     */
+    function clusterlane_icon( string $name ): string {
+        $glyphs = [
+            'cluster'    => '<circle cx="6" cy="7" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="3"/><path d="M8.2 8.3l2.6 7.3M15.9 7.6l-2.8 7.8M8.5 6.8l7-.6"/>',
+            'article'    => '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5"/><path d="M8 8h6M8 12h3"/><circle cx="16.5" cy="15.5" r="3.5"/><path d="M19 18l2.5 2.5"/>',
+            'text-match' => '<path d="M4 6h11M4 10h7M4 14h4"/><circle cx="15.5" cy="14.5" r="3.5"/><path d="M18 17l3 3"/>',
+            'link'       => '<path d="M10 13a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1 1"/><path d="M14 11a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1-1"/>',
+            'admin'      => '<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M8.5 6h7M6 8.5v7M8.3 8.3l6.4 6.4"/>',
+            'settings'   => '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
+            'report'     => '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5"/><path d="M9 17v-3M12 17v-6M15 17v-4"/>',
+            'seo'        => '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h8M7 13h10M7 17h6"/>',
+            'help'       => '<circle cx="12" cy="12" r="9"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.6 2.3c-.7.3-1.2.9-1.2 1.6v.4"/><path d="M12 17h.01"/>',
+        ];
+        $g = $glyphs[ $name ] ?? $glyphs['cluster'];
+        return '<span class="clusterlane-inspector-head__mark" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' . $g . '</svg></span>';
+    }
+}
+
+if ( ! function_exists( 'clusterlane_glyph_paths' ) ) {
+    /** Line glyphs (24×24, stroke = currentColor) — one set for PHP and JS (clusterlaneGlyph). */
+    function clusterlane_glyph_paths(): array {
+        return [
+            'sparkle' => '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 3v4M17 5h4"/>',
+            'clipboard' => '<rect x="6" y="4" width="12" height="17" rx="2"/><path d="M9 4V3h6v1M9 10h6M9 14h6M9 18h3"/>',
+            'folder' => '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
+            'tag' => '<path d="M3 12V4h8l9 9-8 8z"/><circle cx="7.5" cy="8.5" r="1.3"/>',
+            'pencil' => '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
+            'alert' => '<path d="M12 3.5l9 16H3z"/><path d="M12 10v4M12 17h.01"/>',
+            'search' => '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.3-4.3"/>',
+            'expand' => '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
+            'eye' => '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/>',
+            'globe' => '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
+            'chart' => '<path d="M4 20V4M4 20h16"/><path d="M8 16v-4M12 16V8M16 16v-6"/>',
+            'link' => '<path d="M10 13a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1 1"/><path d="M14 11a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1-1"/>',
+            'nodes' => '<circle cx="6" cy="7" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="3"/><path d="M8.2 8.3l2.6 7.3M15.9 7.6l-2.8 7.8M8.5 6.8l7-.6"/>',
+            'bolt' => '<path d="M13 2L4 14h7l-1 8 9-12h-7z"/>',
+            'refresh' => '<path d="M20 11a8 8 0 0 0-14.3-4.9L4 8"/><path d="M4 4v4h4"/><path d="M4 13a8 8 0 0 0 14.3 4.9L20 16"/><path d="M20 20v-4h-4"/>',
+            'bulb' => '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3z"/>',
+            'plug' => '<path d="M9 2v5M15 2v5"/><path d="M6 7h12v4a6 6 0 0 1-12 0z"/><path d="M12 17v5"/>',
+            'wrench' => '<path d="M14.7 6.3a4 4 0 0 1 5-1.3l-2.6 2.6 1.3 1.3 2.6-2.6a4 4 0 0 1-5.3 5L9 18.1a2 2 0 0 1-2.9-2.9l6.8-6.8a4 4 0 0 1 1.8-2.1z"/>',
+            'trash' => '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
+            'check' => '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.7 2.7L16 10"/>',
+            'book' => '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z"/><path d="M4 21V5"/>',
+            'map' => '<path d="M3 6l6-3 6 3 6-3v15l-6 3-6-3-6 3z"/><path d="M9 3v15M15 6v15"/>',
+            'database' => '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
+            'lock' => '<rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
+            'layers' => '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
+            'server' => '<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>',
+            'shield' => '<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/>',
+            'code' => '<path d="M8 8l-4 4 4 4M16 8l4 4-4 4M13.5 5l-3 14"/>',
+            'scale' => '<path d="M12 3v18M7 21h10M5 7h14"/><path d="M5 7l-3 6a3 3 0 0 0 6 0zM19 7l-3 6a3 3 0 0 0 6 0z"/>',
+            'sliders' => '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
+            'printer' => '<path d="M7 9V3h10v6"/><rect x="3" y="9" width="18" height="8" rx="2"/><path d="M7 14h10v7H7z"/>',
+            'key' => '<circle cx="8" cy="15" r="4"/><path d="M10.8 12.2L20 3M17 6l3 3M15 8l2 2"/>',
+            'coins' => '<ellipse cx="9" cy="7" rx="6" ry="3"/><path d="M3 7v5c0 1.7 2.7 3 6 3s6-1.3 6-3V7"/><path d="M9 15v2c0 1.7 2.7 3 6 3s6-1.3 6-3v-5c0-1.7-2.7-3-6-3"/>',
+            'award' => '<circle cx="12" cy="9" r="6"/><path d="M8.5 14l-1.5 7 5-3 5 3-1.5-7"/>',
+            'target' => '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/>',
+            'arrow' => '<path d="M5 12h14M13 6l6 6-6 6"/>',
+            'leaf' => '<path d="M5 19c0-9 5-14 15-14 0 10-5 15-14 15"/><path d="M5 19l7-7"/>',
+            'card' => '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10h10M7 14h6"/>',
+            'download' => '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
+            'bag' => '<path d="M5 8h14l-1 12H6z"/><path d="M9 8V6a3 3 0 0 1 6 0v2"/>',
+            'collapse' => '<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>',
+            'play' => '<path d="M8 5l11 7-11 7z"/>',
+            'dot' => '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
+        ];
+    }
+}
+
+if ( ! function_exists( 'clusterlane_glyph' ) ) {
+    /**
+     * Inline glyph for titles, buttons and labels (replaces emoji, which look
+     * different on every OS). No quotes or newlines, so it is also safe to
+     * print inside a single-quoted JS string.
+     */
+    /** Allowed SVG markup for wp_kses() — exactly what clusterlane_glyph() produces. */
+    function clusterlane_kses_svg(): array {
+        $shape = [ 'd' => true, 'cx' => true, 'cy' => true, 'r' => true, 'rx' => true, 'ry' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'points' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ];
+        return [
+            'svg'      => [ 'class' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'aria-hidden' => true, 'focusable' => true, 'xmlns' => true ],
+            'path'     => $shape, 'circle' => $shape, 'rect' => $shape, 'ellipse' => $shape,
+            'line'     => $shape, 'polyline' => $shape, 'polygon' => $shape, 'g' => $shape,
+        ];
+    }
+
+    /** Allowed markup for clusterlane_icon(): the tile span and its SVG. */
+    function clusterlane_kses_icon(): array {
+        return clusterlane_kses_svg() + [ 'span' => [ 'class' => true, 'aria-hidden' => true ] ];
+    }
+
+    /** Print a page icon tile, escaped (wp_kses with its allowlist). */
+    function clusterlane_icon_e( string $name ): void {
+        echo wp_kses( clusterlane_icon( $name ), clusterlane_kses_icon() );
+    }
+
+    /** Print a glyph, escaped (wp_kses with the SVG allowlist). */
+    function clusterlane_glyph_e( string $name ): void {
+        echo wp_kses( clusterlane_glyph( $name ), clusterlane_kses_svg() );
+    }
+
+    function clusterlane_glyph( string $name ): string {
+        $paths = clusterlane_glyph_paths();
+        if ( ! isset( $paths[ $name ] ) ) return '';
+        return '<svg class="clusterlane-g clusterlane-g--' . $name . '" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $paths[ $name ] . '</svg>';
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-support-panel.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-support-panel.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-support-panel.php	(revision 3751027)
@@ -0,0 +1,796 @@
+<?php
+namespace Clusterlane\Admin;
+
+use Clusterlane\Core\Capabilities;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * SupportPanel
+ *
+ * Admin-only section at the bottom of the Help page: live system status,
+ * access/roles overview, developer hooks and troubleshooting. The same
+ * collected data renders both as HTML and as a plain-text report that
+ * can be copied or downloaded for support.
+ *
+ * Never includes secrets: the AI key is reported only as configured /
+ * decrypts OK, never its value.
+ *
+ * Only rendered for manage_clusterlane — render() checks this itself, so
+ * callers can't accidentally expose it to editors.
+ */
+class SupportPanel {
+
+    const STATUS_OK   = 'ok';
+    const STATUS_WARN = 'warn';
+    const STATUS_INFO = 'info';
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    /* ------------------------------------------------------------------
+       Data collection
+    ------------------------------------------------------------------ */
+
+    /**
+     * @return array<string, array{title:string, icon:string, rows:array<int, array{0:string,1:string,2:string}>}>
+     */
+    public function collect(): array {
+        return [
+            'clusterlane'   => [ 'title' => __( 'Clusterlane', 'clusterlane' ),            'icon' => 'bolt', 'rows' => $this->rows_plugin() ],
+            'data'        => [ 'title' => __( 'Data & scan status', 'clusterlane' ),   'icon' => 'database', 'rows' => $this->rows_data() ],
+            'ai'          => [ 'title' => __( 'AI', 'clusterlane' ),                   'icon' => 'sparkle', 'rows' => $this->rows_ai() ],
+            'access'      => [ 'title' => __( 'Access & roles', 'clusterlane' ),       'icon' => 'lock', 'rows' => $this->rows_access() ],
+            'wordpress'   => [ 'title' => __( 'WordPress', 'clusterlane' ),            'icon' => 'layers', 'rows' => $this->rows_wordpress() ],
+            'server'      => [ 'title' => __( 'Server & PHP', 'clusterlane' ),         'icon' => 'server', 'rows' => $this->rows_server() ],
+            'structure'   => [ 'title' => __( 'Site structure', 'clusterlane' ),       'icon' => 'map', 'rows' => $this->rows_structure() ],
+            'plugins'     => [ 'title' => __( 'Active plugins', 'clusterlane' ),       'icon' => 'nodes', 'rows' => $this->rows_plugins() ],
+        ];
+    }
+
+    private function rows_plugin(): array {
+        $schema_installed = (string) get_option( \Clusterlane\Core\Installer::VERSION_KEY, '' );
+        $schema_expected  = \Clusterlane\Core\Installer::SCHEMA_VERSION;
+        $caps_version     = (string) get_option( Capabilities::VERSION_KEY, '' );
+
+        return [
+            [ __( 'Plugin version', 'clusterlane' ), CLUSTERLANE_VERSION, self::STATUS_INFO ],
+            [ __( 'Edition', 'clusterlane' ), \Clusterlane\Core\Edition::label() . ( defined( 'CLUSTERLANE_EDITION' ) && \Clusterlane\Core\Edition::premium_code() ? ' (CLUSTERLANE_EDITION)' : '' ) . ( \Clusterlane\Core\Edition::premium_code() ? '' : ' — ' . __( 'free build', 'clusterlane' ) ), self::STATUS_INFO ],
+            ...$this->rows_license(),
+            [ __( 'DB schema', 'clusterlane' ),
+                $schema_installed . ( $schema_installed === $schema_expected ? '' : ' (expected ' . $schema_expected . ')' ),
+                $schema_installed === $schema_expected ? self::STATUS_OK : self::STATUS_WARN ],
+            [ __( 'Role mapping version', 'clusterlane' ),
+                $caps_version !== '' ? $caps_version : __( 'not applied', 'clusterlane' ),
+                $caps_version === Capabilities::VERSION ? self::STATUS_OK : self::STATUS_WARN ],
+        ];
+    }
+
+    /** Pro build only (1.2.0): the Freemius license behind the edition. */
+    private function rows_license(): array {
+        if ( ! function_exists( 'clusterlane_fs' ) ) return [];
+        $fs = clusterlane_fs();
+        if ( ! is_object( $fs ) ) return [];
+        if ( $fs->can_use_premium_code() ) {
+            $plan = method_exists( $fs, 'get_plan_title' ) ? (string) $fs->get_plan_title() : (string) $fs->get_plan_name();
+            return [ [ __( 'License', 'clusterlane' ), $plan . ( $fs->is_trial() ? ' ' . __( '(trial)', 'clusterlane' ) : '' ), self::STATUS_OK ] ];
+        }
+        if ( \Clusterlane\Core\Edition::tier() !== 'free' ) {
+            // No license, but a clusterlane_edition filter sets the edition (e.g. the owner's own sites).
+            return [ [ __( 'License', 'clusterlane' ), __( 'none — the edition is set by the clusterlane_edition filter', 'clusterlane' ), self::STATUS_INFO ] ];
+        }
+        return [ [ __( 'License', 'clusterlane' ),
+            $fs->is_registered() ? __( 'no active license — running with the free features', 'clusterlane' ) : __( 'not activated — running with the free features', 'clusterlane' ),
+            self::STATUS_WARN ] ];
+    }
+
+    private function rows_data(): array {
+        global $wpdb;
+        $rows = [];
+
+        $tables = [ 'clusterlane_clusters', 'clusterlane_post_metrics', 'clusterlane_cluster_post_class', 'clusterlane_links',
+                    'clusterlane_ai_cache', 'clusterlane_custom_members', 'clusterlane_tms_skipped', 'clusterlane_snapshots', 'clusterlane_activity' ];
+        $missing = [];
+        $counts  = [];
+        foreach ( $tables as $bare ) {
+            $table = $wpdb->prefix . $bare;
+            if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
+                $missing[] = $bare;
+                continue;
+            }
+            $counts[ $bare ] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
+        }
+
+        $rows[] = [ __( 'Tables', 'clusterlane' ),
+            $missing ? sprintf( /* translators: %s: text */ __( 'missing: %s', 'clusterlane' ), implode( ', ', $missing ) ) : sprintf( /* translators: %d: number of tables */ __( 'all %d present', 'clusterlane' ), count( $tables ) ),
+            $missing ? self::STATUS_WARN : self::STATUS_OK ];
+
+        $scanner = $this->plugin->get( 'scanner' );
+        $summary = $scanner ? $scanner->get_summary() : [];
+        $total   = (int) ( $summary['total_posts'] ?? 0 );
+        $scanned = (int) ( $summary['total_scanned'] ?? 0 );
+
+        $rows[] = [ __( 'Posts scanned', 'clusterlane' ),
+            sprintf( '%d / %d', $scanned, $total ),
+            ( $total > 0 && $scanned >= $total ) ? self::STATUS_OK : self::STATUS_WARN ];
+
+        $last = $summary['last_scan'] ?? null;
+        $rows[] = [ __( 'Last scan', 'clusterlane' ),
+            $last ? $last . ' UTC (' . human_time_diff( strtotime( $last . ' UTC' ) ) . ' ' . __( 'ago', 'clusterlane' ) . ')' : __( 'never', 'clusterlane' ),
+            $last ? self::STATUS_INFO : self::STATUS_WARN ];
+
+        $rows[] = [ __( 'Orphan articles', 'clusterlane' ), (string) (int) ( $summary['orphan_count'] ?? 0 ), self::STATUS_INFO ];
+
+        if ( isset( $counts['clusterlane_clusters'] ) ) {
+            // Same definition as the dashboard hero: is_active = 1.
+            $active   = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}clusterlane_clusters` WHERE is_active = 1" );
+            $inactive = $counts['clusterlane_clusters'] - $active;
+            $rows[] = [ __( 'Clusters', 'clusterlane' ),
+                $inactive > 0
+                    ? sprintf( /* translators: %1$d: number, %2$d: number, %3$d: number */ __( '%1$d (%2$d active, %3$d inactive)', 'clusterlane' ), $counts['clusterlane_clusters'], $active, $inactive )
+                    : sprintf( /* translators: %d: number */ __( '%d (all active)', 'clusterlane' ), $active ),
+                self::STATUS_INFO ];
+        }
+
+        foreach ( [ 'clusterlane_links' => __( 'Links indexed', 'clusterlane' ),
+                    'clusterlane_ai_cache' => __( 'AI cache entries', 'clusterlane' ), 'clusterlane_custom_members' => __( 'Custom cluster members', 'clusterlane' ),
+                    'clusterlane_tms_skipped' => __( 'Skipped text matches', 'clusterlane' ) ] as $bare => $label ) {
+            if ( isset( $counts[ $bare ] ) ) {
+                $value = (string) $counts[ $bare ];
+                if ( $bare === 'clusterlane_ai_cache' && $counts[ $bare ] > 0 ) {
+                    $bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(response_data)) FROM `{$wpdb->prefix}clusterlane_ai_cache`" );
+                    $value .= ' (' . size_format( $bytes, 1 ) . ')';
+                }
+                $rows[] = [ $label, $value, self::STATUS_INFO ];
+            }
+        }
+
+        // History (0.98.124): daily snapshots and the activity log.
+        if ( isset( $counts['clusterlane_snapshots'] ) ) {
+            $h    = \Clusterlane\Core\History::stats();
+            $next = wp_next_scheduled( \Clusterlane\Core\History::CRON_HOOK );
+            $rows[] = [ __( 'Snapshots', 'clusterlane' ),
+                $h['count']
+                    ? sprintf( /* translators: %1$d: number, %2$s: date, %3$s: date */ __( '%1$d (%2$s – %3$s)', 'clusterlane' ), $h['count'], $h['first'], $h['last'] )
+                    : __( 'none yet — taken after each scan and once a day', 'clusterlane' ),
+                self::STATUS_INFO ];
+            $rows[] = [ __( 'Daily snapshot', 'clusterlane' ),
+                $next ? sprintf( /* translators: %s: date and time */ __( 'next %s', 'clusterlane' ), wp_date( 'Y-m-d H:i', $next ) ) : __( 'not scheduled', 'clusterlane' ),
+                $next ? self::STATUS_OK : self::STATUS_WARN ];
+        }
+        if ( isset( $counts['clusterlane_activity'] ) ) {
+            $rows[] = [ __( 'Activity log', 'clusterlane' ), sprintf( /* translators: %d: number */ _n( '%d entry', '%d entries', $counts['clusterlane_activity'], 'clusterlane' ), $counts['clusterlane_activity'] ), self::STATUS_INFO ];
+        }
+
+        $settings = $this->plugin->get( 'settings' );
+        if ( $settings ) {
+            $configured = $settings->cluster_terms_configured();
+            $rows[] = [ __( 'Cluster setup completed', 'clusterlane' ), $configured ? __( 'yes', 'clusterlane' ) : __( 'no', 'clusterlane' ),
+                $configured ? self::STATUS_OK : self::STATUS_WARN ];
+            $rows[] = [ __( 'Enabled categories / tags', 'clusterlane' ),
+                count( $settings->enabled_categories() ) . ' / ' . count( $settings->enabled_tags() ), self::STATUS_INFO ];
+            $rows[] = [ __( 'Scan batch size', 'clusterlane' ), (string) (int) $settings->get( 'batch_size', 25 ), self::STATUS_INFO ];
+        }
+
+        return $rows;
+    }
+
+    /**
+     * How the site's content is organised (1.1.1): which post types hold the
+     * content and whether they are scanned, which taxonomies sort it, and how
+     * well that turns into clusters. Explains the usual reasons Clusterlane
+     * finds little on a site with plenty of content — articles in a custom
+     * post type that isn't scanned, most posts left in the default category,
+     * one category holding nearly everything, or categories that aren't used
+     * as clusters while another taxonomy (regions, topics) carries the structure.
+     */
+    private function rows_structure(): array {
+        global $wpdb;
+        $settings = $this->plugin->get( 'settings' );
+        if ( ! $settings ) return [ [ __( 'Settings', 'clusterlane' ), __( 'unavailable', 'clusterlane' ), self::STATUS_WARN ] ];
+
+        $rows     = [];
+        $scanned  = array_values( array_filter( array_map( 'sanitize_key', (array) $settings->post_types() ) ) );
+        $rendered = $settings->rendered_types();
+
+        // 1. Post types with their own pages, scanned or not.
+        $types = get_post_types( [ 'public' => true ], 'objects' ) + get_post_types( [ 'publicly_queryable' => true ], 'objects' );
+        unset( $types['attachment'] );
+        foreach ( $types as $slug => $obj ) {
+            $n          = (int) ( wp_count_posts( $slug )->publish ?? 0 );
+            $is_scanned = in_array( $slug, $scanned, true );
+            if ( ! $is_scanned && $n === 0 ) continue;
+            // Pages are usually fixed site pages (About, Contact) — not scanning them is normal.
+            $warn  = ! $is_scanned && $slug !== 'page' && $n >= 20;
+            $value = sprintf( /* translators: %d: number of published items */ _n( '%d published', '%d published', $n, 'clusterlane' ), $n ) . ' · '
+                   . ( $is_scanned
+                        ? ( in_array( $slug, $rendered, true ) ? __( 'scanned, pages read', 'clusterlane' ) : __( 'scanned', 'clusterlane' ) )
+                        : __( 'not scanned', 'clusterlane' ) );
+            if ( $warn ) $value .= ' — ' . __( 'if these are articles, add the type under Settings → Content', 'clusterlane' );
+            $rows[] = [ $obj->labels->name . ' (' . $slug . ')', $value, $is_scanned ? self::STATUS_OK : ( $warn ? self::STATUS_WARN : self::STATUS_INFO ) ];
+        }
+
+        if ( ! $scanned ) {
+            $rows[] = [ __( 'Scanned types', 'clusterlane' ), __( 'none selected', 'clusterlane' ), self::STATUS_WARN ];
+            return $rows;
+        }
+
+        $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")", $scanned ) );
+        if ( $total === 0 ) return $rows;
+
+        // Active clusters per source (taxonomy name, 'custom' or hub_<type>).
+        $sources = [];
+        $ctable  = $wpdb->prefix . 'clusterlane_clusters';
+        $has_ct  = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ctable ) ) === $ctable;
+        if ( $has_ct ) {
+            foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT cluster_type, COUNT(*) AS n FROM %i WHERE is_active = 1 GROUP BY cluster_type', $ctable ) ) as $r ) {
+                $sources[ (string) $r->cluster_type ] = (int) $r->n;
+            }
+        }
+
+        // 2. Taxonomies that sort the scanned content.
+        foreach ( get_object_taxonomies( $scanned, 'objects' ) as $tax => $obj ) {
+            if ( $tax === 'post_format' || ( ! $obj->public && ! $obj->show_ui ) ) continue;
+            $r = $wpdb->get_row( $wpdb->prepare(
+                "SELECT COUNT(DISTINCT tt.term_id) AS terms, COUNT(*) AS rels, COUNT(DISTINCT p.ID) AS posts
+                   FROM {$wpdb->term_relationships} tr
+                   JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
+                   JOIN {$wpdb->posts} p ON p.ID = tr.object_id
+                  WHERE tt.taxonomy = %s AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")",
+                array_merge( [ $tax ], $scanned )
+            ) );
+            if ( ! $r || ! (int) $r->terms ) continue; // registered but unused
+            $posts    = (int) $r->posts;
+            $clusters = (int) ( $sources[ $tax ] ?? 0 );
+            $parts    = [
+                sprintf( /* translators: %d: number of terms */ _n( '%d term', '%d terms', (int) $r->terms, 'clusterlane' ), (int) $r->terms ),
+                sprintf( /* translators: %d: percent of articles */ __( 'on %d%% of articles', 'clusterlane' ), (int) round( 100 * $posts / $total ) ),
+                sprintf( /* translators: %s: average number of terms */ __( '%s per article', 'clusterlane' ), number_format_i18n( (int) $r->rels / max( 1, $posts ), 1 ) ),
+            ];
+            if ( $obj->hierarchical ) {
+                $depth = self::term_depth( $tax );
+                if ( $depth > 1 ) $parts[] = sprintf( /* translators: %d: number of levels */ __( '%d levels', 'clusterlane' ), $depth );
+            }
+            $parts[] = $clusters
+                ? sprintf( /* translators: %d: number of clusters */ _n( '%d used as cluster', '%d used as clusters', $clusters, 'clusterlane' ), $clusters )
+                : __( 'not used as clusters', 'clusterlane' );
+            $rows[] = [ $obj->labels->name . ' (' . $tax . ')', implode( ' · ', $parts ), self::STATUS_INFO ];
+        }
+
+        $other = array_diff_key( $sources, array_flip( get_object_taxonomies( $scanned ) ) );
+        if ( $other ) {
+            $labels = [];
+            foreach ( $other as $type => $n ) {
+                $labels[] = ( $type === 'custom' ? __( 'custom', 'clusterlane' ) : ( str_starts_with( $type, 'hub_' ) ? __( 'hub pages', 'clusterlane' ) : $type ) ) . ': ' . $n;
+            }
+            $rows[] = [ __( 'Other clusters', 'clusterlane' ), implode( ' · ', $labels ), self::STATUS_INFO ];
+        }
+
+        // 3. Posts left only in the default category ("Uncategorized").
+        $default = (int) get_option( 'default_category' );
+        if ( $default && in_array( 'post', $scanned, true ) ) {
+            $only = (int) $wpdb->get_var( $wpdb->prepare(
+                "SELECT COUNT(*) FROM (
+                    SELECT p.ID FROM {$wpdb->posts} p
+                      JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
+                      JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'category'
+                     WHERE p.post_type = 'post' AND p.post_status = 'publish'
+                     GROUP BY p.ID HAVING COUNT(*) = 1 AND MAX(tt.term_id) = %d
+                 ) x",
+                $default
+            ) );
+            if ( $only > 0 ) {
+                $term = get_term( $default, 'category' );
+                $pct  = (int) round( 100 * $only / $total );
+                $warn = $only >= 10 && $pct >= 20;
+                $rows[] = [
+                    sprintf( /* translators: %s: category name */ __( 'Only in “%s”', 'clusterlane' ), $term && ! is_wp_error( $term ) ? $term->name : __( 'default category', 'clusterlane' ) ),
+                    sprintf( /* translators: %1$d: number, %2$d: percent */ __( '%1$d articles (%2$d%%)', 'clusterlane' ), $only, $pct )
+                        . ( $warn ? ' — ' . __( 'give them a real category so they can join a cluster', 'clusterlane' ) : '' ),
+                    $warn ? self::STATUS_WARN : self::STATUS_INFO,
+                ];
+            }
+        }
+
+        // 4. How the scanned articles spread over the active clusters.
+        $mtable = $wpdb->prefix . 'clusterlane_post_metrics';
+        $ptable = $wpdb->prefix . 'clusterlane_cluster_post_class';
+        if ( $has_ct && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $ptable ) ) === $ptable && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $mtable ) ) === $mtable ) {
+            $n_scanned = (int) $wpdb->get_var( $wpdb->prepare(
+                "SELECT COUNT(*) FROM %i m JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")",
+                array_merge( [ $mtable ], $scanned )
+            ) );
+            if ( $n_scanned > 0 ) {
+                $outside = (int) $wpdb->get_var( $wpdb->prepare(
+                    "SELECT COUNT(*) FROM %i m JOIN {$wpdb->posts} p ON p.ID = m.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")
+                      WHERE NOT EXISTS (
+                        SELECT 1 FROM %i c JOIN %i k ON k.cluster_key = c.cluster_key AND k.is_active = 1
+                         WHERE c.post_id = m.post_id )",
+                    array_merge( [ $mtable ], $scanned, [ $ptable, $ctable ] )
+                ) );
+                $pct  = (int) round( 100 * $outside / $n_scanned );
+                $warn = $n_scanned >= 20 && $pct >= 40;
+                $rows[] = [ __( 'Articles in no cluster', 'clusterlane' ),
+                    sprintf( /* translators: %1$d: number, %2$d: number, %3$d: percent */ __( '%1$d of %2$d (%3$d%%)', 'clusterlane' ), $outside, $n_scanned, $pct )
+                        . ( $warn ? ' — ' . __( 'choose more categories or tags in Cluster Admin, or the taxonomy that carries the structure', 'clusterlane' ) : '' ),
+                    $warn ? self::STATUS_WARN : ( $outside === 0 ? self::STATUS_OK : self::STATUS_INFO ) ];
+
+                $sizes = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT total_members FROM %i WHERE is_active = 1 ORDER BY total_members', $ctable ) ) );
+                if ( count( $sizes ) > 1 ) {
+                    $rows[] = [ __( 'Cluster sizes', 'clusterlane' ),
+                        sprintf( /* translators: %1$d: smallest, %2$d: median, %3$d: largest */ __( 'smallest %1$d · median %2$d · largest %3$d', 'clusterlane' ),
+                            $sizes[0], $sizes[ intdiv( count( $sizes ), 2 ) ], end( $sizes ) ),
+                        self::STATUS_INFO ];
+                }
+                $big = $wpdb->get_row( $wpdb->prepare( 'SELECT cluster_label, total_members FROM %i WHERE is_active = 1 ORDER BY total_members DESC LIMIT 1', $ctable ) );
+                if ( $big && count( $sizes ) > 1 ) {
+                    $share = (int) round( 100 * (int) $big->total_members / $n_scanned );
+                    $warn  = $n_scanned >= 30 && $share > 50;
+                    $rows[] = [ __( 'Largest cluster', 'clusterlane' ),
+                        sprintf( /* translators: %1$s: cluster name, %2$d: number, %3$d: percent */ __( '%1$s — %2$d articles (%3$d%%)', 'clusterlane' ), (string) $big->cluster_label, (int) $big->total_members, $share )
+                            . ( $warn ? ' — ' . __( 'very broad; subcategories or tags give sharper clusters', 'clusterlane' ) : '' ),
+                        $warn ? self::STATUS_WARN : self::STATUS_INFO ];
+                }
+            }
+        }
+
+        // 5. Page builders on scanned articles (text is read, but some writes are blocked).
+        $builders = [];
+        if ( defined( 'ELEMENTOR_VERSION' ) ) $builders['Elementor'] = [ '_elementor_edit_mode', 'builder' ];
+        if ( defined( 'FL_BUILDER_VERSION' ) ) $builders['Beaver Builder'] = [ '_fl_builder_enabled', '1' ];
+        foreach ( $builders as $name => [ $key, $val ] ) {
+            $n = (int) $wpdb->get_var( $wpdb->prepare(
+                "SELECT COUNT(*) FROM {$wpdb->postmeta} pm JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+                  WHERE pm.meta_key = %s AND pm.meta_value = %s AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $scanned ), '%s' ) ) . ")",
+                array_merge( [ $key, $val ], $scanned )
+            ) );
+            if ( $n ) {
+                $rows[] = [ $name, sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d of %2$d articles', 'clusterlane' ), $n, $total ), self::STATUS_INFO ];
+            }
+        }
+
+        return $rows;
+    }
+
+    /** Number of levels in a hierarchical taxonomy (1 = flat). */
+    private static function term_depth( string $tax ): int {
+        global $wpdb;
+        $parents = [];
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT term_id, parent FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s", $tax ) ) as $r ) {
+            $parents[ (int) $r->term_id ] = (int) $r->parent;
+        }
+        $max = 0;
+        foreach ( array_keys( $parents ) as $id ) {
+            $d = 1;
+            $seen = [ $id => true ];
+            while ( ( $p = $parents[ $id ] ?? 0 ) && isset( $parents[ $p ] ) && ! isset( $seen[ $p ] ) && $d < 20 ) {
+                $seen[ $p ] = true;
+                $id = $p;
+                $d++;
+            }
+            $max = max( $max, $d );
+        }
+        return $max;
+    }
+
+    /** "Claude Opus 5.5 (claude-opus-5-5)" — readable name plus the exact ID for support. */
+    private static function model_name( string $model ): string {
+        $cat = class_exists( \Clusterlane\Core\AiClient::class ) ? \Clusterlane\Core\AiClient::catalog() : [];
+        return isset( $cat[ $model ] ) ? $cat[ $model ]['label'] . ' (' . $model . ')' : $model;
+    }
+
+    private function rows_ai(): array {
+        $settings = $this->plugin->get( 'settings' );
+        if ( ! $settings ) return [ [ __( 'Settings', 'clusterlane' ), __( 'unavailable', 'clusterlane' ), self::STATUS_WARN ] ];
+
+        // No AI in this edition (or premium code not installed): one neutral row, no key warnings.
+        if ( ! \Clusterlane\Core\Edition::has( 'ai' ) || ! class_exists( \Clusterlane\Core\AiUsage::class ) ) {
+            return [ [ __( 'AI features', 'clusterlane' ), \Clusterlane\Core\Edition::missing_message( 'ai' ), self::STATUS_INFO ] ];
+        }
+
+        $source   = $settings->ai_source(); // site | network | none
+        $has_key  = $source !== 'none';
+        // A key can be stored but undecryptable (e.g. AUTH_KEY changed after a migration).
+        $decrypts = $has_key && $settings->ai_api_key() !== '';
+        $where    = $source === 'network' ? __( 'Network Admin → Clusterlane', 'clusterlane' ) : __( 'Settings', 'clusterlane' );
+
+        $rows = [
+            [ __( 'Provider', 'clusterlane' ), $settings->ai_provider(), self::STATUS_INFO ],
+            [ __( 'Everyday model', 'clusterlane' ), self::model_name( $settings->ai_model() ), self::STATUS_INFO ],
+            [ __( 'Analysis model', 'clusterlane' ),
+                $source === 'network'
+                    ? __( 'network key — one model for everything', 'clusterlane' )
+                    : ( (string) $settings->get( 'ai_model_analysis', '' ) !== ''
+                        ? self::model_name( (string) $settings->get( 'ai_model_analysis', '' ) )
+                        : __( 'same as everyday model', 'clusterlane' ) ),
+                self::STATUS_INFO ],
+            [ __( 'Tone of analyses', 'clusterlane' ),
+                (string) $settings->get( 'ai_tone', 'editorial' ) === 'technical' ? __( 'technical', 'clusterlane' ) : __( 'editorial', 'clusterlane' ),
+                self::STATUS_INFO ],
+            [ __( 'API key', 'clusterlane' ),
+                ! $has_key ? __( 'not configured', 'clusterlane' ) : ( $decrypts ? __( 'configured, decrypts OK', 'clusterlane' ) : sprintf( /* translators: %s: text */ __( 'stored but cannot be decrypted — re-enter it in %s', 'clusterlane' ), $where ) ),
+                $decrypts ? self::STATUS_OK : self::STATUS_WARN ],
+        ];
+
+        $u     = \Clusterlane\Core\AiUsage::current();
+        $limit = \Clusterlane\Core\AiUsage::user_limit( $settings );
+        $rows[] = [ __( 'Usage this month', 'clusterlane' ),
+            sprintf( /* translators: %1$d: number, %2$s: text, %3$s: text */ __( '%1$d calls · %2$s tokens · %3$s est.', 'clusterlane' ), (int) $u['calls'], number_format_i18n( $u['input'] + $u['output'] ), \Clusterlane\Core\AiUsage::format_cost( (float) $u['cost'] ) ),
+            self::STATUS_INFO ];
+        // Calls (and cost, where tracked) per model this month.
+        $models = (array) ( $u['models'] ?? [] );
+        if ( $models ) {
+            arsort( $models );
+            $cat   = class_exists( \Clusterlane\Core\AiClient::class ) ? \Clusterlane\Core\AiClient::catalog() : [];
+            $parts = [];
+            foreach ( $models as $m => $n ) {
+                $cost    = $u['model_cost'][ $m ] ?? null;
+                $parts[] = ( $cat[ $m ]['label'] ?? $m ) . ': ' . (int) $n
+                    . ( $cost !== null ? ' (' . \Clusterlane\Core\AiUsage::format_cost( (float) $cost ) . ')' : '' );
+            }
+            $rows[] = [ __( 'Calls by model', 'clusterlane' ), implode( ' · ', $parts ), self::STATUS_INFO ];
+        }
+        $rows[] = [ __( 'Editor limit', 'clusterlane' ),
+            $limit ? sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d / %2$d calls', 'clusterlane' ), (int) $u['calls_limited'], $limit ) : __( 'none', 'clusterlane' ),
+            ( $limit && $u['calls_limited'] >= $limit ) ? self::STATUS_WARN : self::STATUS_INFO ];
+
+        if ( class_exists( \Clusterlane\Core\NetworkAi::class ) && \Clusterlane\Core\NetworkAi::enabled() ) {
+            if ( $source === 'network' && \Clusterlane\Core\NetworkAi::site_limit() > 0 ) {
+                $nl = \Clusterlane\Core\NetworkAi::site_limit();
+                $rows[] = [ __( 'Network allowance', 'clusterlane' ), sprintf( /* translators: %1$d: number, %2$d: number */ __( '%1$d / %2$d calls', 'clusterlane' ), (int) $u['calls'], $nl ),
+                    $u['calls'] >= $nl ? self::STATUS_WARN : self::STATUS_INFO ];
+            }
+            $rows[] = [ __( 'Key source', 'clusterlane' ),
+                [ 'site' => __( 'this site’s own key', 'clusterlane' ), 'network' => __( 'network key', 'clusterlane' ), 'none' => __( 'none', 'clusterlane' ) ][ $source ],
+                self::STATUS_INFO ];
+            $rows[] = [ __( 'Network policy', 'clusterlane' ),
+                \Clusterlane\Core\NetworkAi::site_keys_allowed() ? __( 'site keys allowed', 'clusterlane' ) : __( 'network key only', 'clusterlane' ),
+                self::STATUS_INFO ];
+        }
+        return $rows;
+    }
+
+    private function rows_access(): array {
+        $rows   = [];
+        $counts = count_users();
+        $avail  = $counts['avail_roles'] ?? [];
+
+        foreach ( wp_roles()->roles as $slug => $role ) {
+            $caps   = $role['capabilities'] ?? [];
+            $manage = ! empty( $caps[ Capabilities::MANAGE ] );
+            $use    = ! empty( $caps[ Capabilities::USE ] );
+            $fallback = ! empty( $caps['manage_options'] ) && apply_filters( 'clusterlane_admin_cap_fallback', true );
+            if ( ! $manage && ! $use && ! $fallback ) continue;
+
+            $ai    = $manage || $fallback || ! empty( $caps[ Capabilities::USE_AI ] );
+            $links = $manage || $fallback || ! empty( $caps[ Capabilities::LINKS ] );
+            $seo   = $manage || $fallback || ! empty( $caps[ Capabilities::SEO ] );
+            $level = ( ( $manage || $fallback ) ? 'manage + use' : 'use' ) . ( $ai ? ' + AI' : '' )
+                   . ( $links ? ' + links' : '' ) . ( $seo ? ' + SEO' : '' );
+            if ( ! $manage && $fallback ) $level .= ' ' . __( '(via manage_options fallback)', 'clusterlane' );
+
+            $rows[] = [
+                translate_user_role( $role['name'] ) . ' (' . $slug . ')',
+                $level . ' · ' . sprintf( /* translators: %d: number */ _n( '%d user', '%d users', (int) ( $avail[ $slug ] ?? 0 ), 'clusterlane' ), (int) ( $avail[ $slug ] ?? 0 ) ),
+                self::STATUS_INFO,
+            ];
+        }
+
+        if ( ! $rows ) {
+            $rows[] = [ __( 'Roles', 'clusterlane' ), __( 'no role has Clusterlane access', 'clusterlane' ), self::STATUS_WARN ];
+        }
+
+        $rows[] = [ __( 'Admin lock-out fallback', 'clusterlane' ),
+            apply_filters( 'clusterlane_admin_cap_fallback', true ) ? __( 'active', 'clusterlane' ) : __( 'disabled by filter', 'clusterlane' ),
+            self::STATUS_INFO ];
+        $rows[] = [ __( 'Role map filtered', 'clusterlane' ),
+            has_filter( 'clusterlane_role_capabilities' ) ? __( 'yes (custom mapping)', 'clusterlane' ) : __( 'no (defaults)', 'clusterlane' ),
+            self::STATUS_INFO ];
+
+        return $rows;
+    }
+
+    private function rows_wordpress(): array {
+        global $wp_version;
+        $theme  = wp_get_theme();
+        $parent = $theme->parent();
+
+        $rows = [
+            [ __( 'Site URL', 'clusterlane' ), home_url(), self::STATUS_INFO ],
+            [ __( 'WordPress', 'clusterlane' ), (string) $wp_version, self::STATUS_INFO ],
+            ...$this->rows_multisite(),
+            [ __( 'Language', 'clusterlane' ), get_locale(), self::STATUS_INFO ],
+            [ __( 'Multilingual', 'clusterlane' ), \Clusterlane\Core\Lang::active() ? ( \Clusterlane\Core\Lang::plugin() === 'wpml' ? 'WPML' : 'Polylang' ) . ': ' . implode( ', ', array_keys( \Clusterlane\Core\Lang::languages() ) ) : __( 'No', 'clusterlane' ), self::STATUS_INFO ],
+            $this->row_timezone(),
+            [ __( 'Theme', 'clusterlane' ),
+                $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ) . ( $parent ? ' (child of ' . $parent->get( 'Name' ) . ' ' . $parent->get( 'Version' ) . ')' : '' ),
+                self::STATUS_INFO ],
+            ...$this->rows_debug(),
+            [ 'WP_MEMORY_LIMIT', defined( 'WP_MEMORY_LIMIT' ) ? (string) WP_MEMORY_LIMIT : '—', self::STATUS_INFO ],
+            [ 'DISABLE_WP_CRON', ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ? 'true' : 'false', self::STATUS_INFO ],
+        ];
+        return $rows;
+    }
+
+    private function rows_multisite(): array {
+        if ( ! is_multisite() ) {
+            return [ [ __( 'Multisite', 'clusterlane' ), __( 'no', 'clusterlane' ), self::STATUS_INFO ] ];
+        }
+        return [
+            [ __( 'Multisite', 'clusterlane' ),
+                sprintf( /* translators: 1: activation mode, 2: number of sites */ __( 'yes · %1$s · %2$d sites', 'clusterlane' ),
+                    is_subdomain_install() ? __( 'subdomain', 'clusterlane' ) : __( 'subdirectory', 'clusterlane' ),
+                    (int) get_sites( [ 'count' => true ] ) ),
+                self::STATUS_INFO ],
+            [ __( 'This site ID', 'clusterlane' ), (string) get_current_blog_id() . ( is_main_site() ? ' ' . __( '(main site)', 'clusterlane' ) : '' ), self::STATUS_INFO ],
+            [ __( 'Clusterlane activation', 'clusterlane' ),
+                \Clusterlane\Core\Multisite::is_network_active() ? __( 'network-wide', 'clusterlane' ) : __( 'this site only', 'clusterlane' ),
+                self::STATUS_INFO ],
+        ];
+    }
+
+    /**
+     * A fixed UTC offset (e.g. "+02:00") doesn't follow daylight saving —
+     * the site clock is an hour off half the year. A city (Europe/Stockholm) does.
+     */
+    private function row_timezone(): array {
+        $tz_string = (string) get_option( 'timezone_string', '' );
+        if ( $tz_string !== '' ) {
+            return [ __( 'Timezone', 'clusterlane' ), $tz_string, self::STATUS_OK ];
+        }
+        return [ __( 'Timezone', 'clusterlane' ),
+            sprintf( /* translators: %s: text */ __( '%s — fixed offset, ignores daylight saving. Pick a city under Settings → General.', 'clusterlane' ), wp_timezone_string() ),
+            self::STATUS_WARN ];
+    }
+
+    /**
+     * WP_DEBUG itself is fine in production as long as errors are logged,
+     * not printed. Warn only when they'd be displayed to visitors.
+     */
+    private function rows_debug(): array {
+        $debug   = defined( 'WP_DEBUG' ) && WP_DEBUG;
+        // WP_DEBUG_DISPLAY defaults to true when undefined.
+        $display = ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY;
+        $log     = defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : false;
+
+        $log_label = $log === false ? 'off' : ( is_string( $log ) ? $log : 'on (wp-content/debug.log)' );
+
+        return [
+            [ 'WP_DEBUG', $debug ? 'on' : 'off', self::STATUS_INFO ],
+            [ 'WP_DEBUG_DISPLAY',
+                ( $display ? 'on' : 'off' ) . ( $debug && $display ? ' ' . __( '— errors may be shown to visitors', 'clusterlane' ) : '' ),
+                $debug && $display ? self::STATUS_WARN : ( $debug ? self::STATUS_OK : self::STATUS_INFO ) ],
+            [ 'WP_DEBUG_LOG', $log_label, self::STATUS_INFO ],
+        ];
+    }
+
+    private function rows_server(): array {
+        global $wpdb;
+
+        $mem_raw   = (string) ini_get( 'memory_limit' );
+        $mem_bytes = wp_convert_hr_to_bytes( $mem_raw );
+        $mem_ok    = $mem_raw === '-1' || $mem_bytes >= 256 * MB_IN_BYTES;
+
+        $disabled = array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) );
+        $stl_ok   = function_exists( 'set_time_limit' ) && ! in_array( 'set_time_limit', $disabled, true );
+
+        $php_ok = version_compare( PHP_VERSION, '8.0', '>=' );
+
+        $cdn = isset( $_SERVER['HTTP_CF_RAY'] ) ? 'Cloudflare' : __( 'none detected', 'clusterlane' );
+
+        return [
+            [ 'PHP', PHP_VERSION, $php_ok ? self::STATUS_OK : self::STATUS_WARN ],
+            [ 'memory_limit', $mem_raw . ( $mem_ok ? '' : ' ' . __( '(256M+ recommended)', 'clusterlane' ) ), $mem_ok ? self::STATUS_OK : self::STATUS_WARN ],
+            [ 'max_execution_time', (string) ini_get( 'max_execution_time' ), self::STATUS_INFO ],
+            [ 'set_time_limit()', $stl_ok ? __( 'available', 'clusterlane' ) : __( 'disabled — long AI calls may time out', 'clusterlane' ), $stl_ok ? self::STATUS_OK : self::STATUS_WARN ],
+            [ 'OPcache', ini_get( 'opcache.enable' ) ? __( 'enabled', 'clusterlane' ) : __( 'disabled', 'clusterlane' ), self::STATUS_INFO ],
+            [ __( 'Database', 'clusterlane' ), $wpdb->db_server_info() ?: $wpdb->db_version(), self::STATUS_INFO ],
+            [ __( 'Web server', 'clusterlane' ), sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '—' ) ), self::STATUS_INFO ],
+            [ __( 'CDN / proxy', 'clusterlane' ), $cdn, self::STATUS_INFO ],
+        ];
+    }
+
+    private function rows_plugins(): array {
+        if ( ! function_exists( 'get_plugins' ) ) {
+            require_once ABSPATH . 'wp-admin/includes/plugin.php';
+        }
+        $all    = get_plugins();
+        $active = (array) get_option( 'active_plugins', [] );
+        $network = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) : [];
+
+        $rows = [];
+        foreach ( array_unique( array_merge( $active, $network ) ) as $file ) {
+            if ( ! isset( $all[ $file ] ) ) continue;
+            $label = $all[ $file ]['Name'];
+            if ( in_array( $file, $network, true ) ) $label .= ' [network]';
+            $rows[] = [ $label, (string) $all[ $file ]['Version'], self::STATUS_INFO ];
+        }
+        usort( $rows, fn( $a, $b ) => strcasecmp( $a[0], $b[0] ) );
+        return $rows ?: [ [ __( 'Plugins', 'clusterlane' ), '—', self::STATUS_INFO ] ];
+    }
+
+    /* ------------------------------------------------------------------
+       Plain-text report
+    ------------------------------------------------------------------ */
+
+    public function to_text( array $sections ): string {
+        $out   = [];
+        $out[] = '=== ' . \Clusterlane\Core\Brand::name() . ' System Report ===';
+        $out[] = 'Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
+        $out[] = 'Contains no API keys or passwords.';
+        $out[] = '';
+
+        $warnings = [];
+        foreach ( $sections as $section ) {
+            foreach ( $section['rows'] as $row ) {
+                if ( $row[2] === self::STATUS_WARN ) $warnings[] = $section['title'] . ' → ' . $row[0] . ': ' . $row[1];
+            }
+        }
+        $out[] = '-- Warnings (' . count( $warnings ) . ') --';
+        $out   = array_merge( $out, $warnings ?: [ 'none' ] );
+        $out[] = '';
+
+        foreach ( $sections as $section ) {
+            $out[] = '-- ' . $section['title'] . ' --';
+            // Cap the label column so one very long plugin name doesn't push
+            // every value far to the right; longer labels are truncated.
+            $max   = 40;
+            $width = 0;
+            foreach ( $section['rows'] as $row ) $width = max( $width, min( $max, mb_strlen( $row[0] ) ) );
+            foreach ( $section['rows'] as $row ) {
+                $label = mb_strlen( $row[0] ) > $max ? mb_substr( $row[0], 0, $max - 1 ) . '…' : $row[0];
+                $flag  = $row[2] === self::STATUS_WARN ? ' [!]' : '';
+                $out[] = $label . str_repeat( ' ', $width - mb_strlen( $label ) ) . ' : ' . $row[1] . $flag;
+            }
+            $out[] = '';
+        }
+        return implode( "\n", $out );
+    }
+
+    /* ------------------------------------------------------------------
+       HTML
+    ------------------------------------------------------------------ */
+
+    public function render(): void {
+        if ( ! Capabilities::can_manage() ) return;
+
+        $sections = $this->collect();
+        $text     = $this->to_text( $sections );
+        $host     = sanitize_file_name( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
+        $filename = 'clusterlane-report-' . $host . '-' . gmdate( 'Ymd-His' ) . '.txt';
+
+        $warn_count = 0;
+        foreach ( $sections as $s ) foreach ( $s['rows'] as $r ) if ( $r[2] === self::STATUS_WARN ) $warn_count++;
+        ?>
+        <div class="clusterlane-support-panel" id="clusterlane-support-panel">
+            <div class="clusterlane-support-header">
+                <div class="clusterlane-support-title">
+                    <span class="clusterlane-support-icon"><?php clusterlane_glyph_e( 'shield' ); ?></span>
+                    <div>
+                        <h2><?php esc_html_e( 'Admin & Support', 'clusterlane' ); ?></h2>
+                        <p><?php esc_html_e( 'Live status for this site. Only visible to Clusterlane administrators.', 'clusterlane' ); ?></p>
+                    </div>
+                </div>
+                <div class="clusterlane-support-actions">
+                    <span class="clusterlane-support-badge <?php echo $warn_count ? 'clusterlane-support-badge--warn' : 'clusterlane-support-badge--ok'; ?>">
+                        <?php
+                        echo $warn_count
+                            ? esc_html( sprintf( /* translators: %d: number */ _n( '%d warning', '%d warnings', $warn_count, 'clusterlane' ), $warn_count ) )
+                            : esc_html__( 'All checks OK', 'clusterlane' );
+                        ?>
+                    </span>
+                    <button type="button" class="clusterlane-support-btn" id="clusterlane-support-copy"><?php clusterlane_glyph_e( 'clipboard' ); ?><?php esc_html_e( 'Copy report', 'clusterlane' ); ?></button>
+                    <button type="button" class="clusterlane-support-btn clusterlane-support-btn--primary" id="clusterlane-support-download"
+                            data-filename="<?php echo esc_attr( $filename ); ?>"><?php clusterlane_glyph_e( 'download' ); ?><?php esc_html_e( 'Download .txt', 'clusterlane' ); ?></button>
+                </div>
+            </div>
+
+            <div class="clusterlane-support-body">
+                <div class="clusterlane-support-grid">
+                    <?php foreach ( $sections as $key => $section ): ?>
+                    <div class="clusterlane-support-card<?php echo $key === 'plugins' ? ' clusterlane-support-card--wide' : ( $key === 'structure' ? ' clusterlane-support-card--full' : '' ); ?>">
+                        <h3><?php clusterlane_glyph_e( $section['icon'] ); ?> <?php echo esc_html( $section['title'] ); ?></h3>
+                        <table class="clusterlane-support-table">
+                            <?php foreach ( $section['rows'] as $row ): ?>
+                            <tr class="clusterlane-support-row--<?php echo esc_attr( $row[2] ); ?>">
+                                <th><?php echo esc_html( $row[0] ); ?></th>
+                                <td>
+                                    <?php if ( $row[2] === self::STATUS_OK ): ?><span class="clusterlane-support-dot clusterlane-support-dot--ok" aria-label="OK"></span><?php endif; ?>
+                                    <?php if ( $row[2] === self::STATUS_WARN ): ?><span class="clusterlane-support-dot clusterlane-support-dot--warn" aria-label="Warning"></span><?php endif; ?>
+                                    <?php echo esc_html( $row[1] ); ?>
+                                </td>
+                            </tr>
+                            <?php endforeach; ?>
+                        </table>
+                    </div>
+                    <?php endforeach; ?>
+                </div>
+
+                <div class="clusterlane-support-grid">
+                    <div class="clusterlane-support-card">
+                        <h3><span><?php clusterlane_glyph_e( 'code' ); ?></span> <?php esc_html_e( 'For developers', 'clusterlane' ); ?></h3>
+                        <p class="clusterlane-support-note"><?php esc_html_e( 'Change which roles get Clusterlane access (e.g. give Authors use access):', 'clusterlane' ); ?></p>
+<pre class="clusterlane-support-code">add_filter( 'clusterlane_role_capabilities', function ( $map ) {
+    $map['author'] = [ 'use_clusterlane' ];
+    return $map;
+} );
+// Re-apply the mapping once:
+// delete_option( 'clusterlane_caps_version' );</pre>
+                        <p class="clusterlane-support-note"><?php esc_html_e( 'Disable the rule that always gives manage_options users full access:', 'clusterlane' ); ?></p>
+<pre class="clusterlane-support-code">add_filter( 'clusterlane_admin_cap_fallback', '__return_false' );</pre>
+                        <p class="clusterlane-support-note"><?php esc_html_e( 'Capabilities: manage_clusterlane (Settings, Cluster Admin, setup) and use_clusterlane (Dashboard, Inspectors, Gap Analysis, Text Match, Link Suggestions).', 'clusterlane' ); ?></p>
+                    </div>
+
+                    <div class="clusterlane-support-card">
+                        <h3><span><?php clusterlane_glyph_e( 'wrench' ); ?></span> <?php esc_html_e( 'Troubleshooting', 'clusterlane' ); ?></h3>
+                        <ol class="clusterlane-support-list">
+                            <li><?php esc_html_e( 'Changes not showing after an update? Clear your page cache / CDN (e.g. Cloudflare "Purge Everything") and reload in a private window.', 'clusterlane' ); ?></li>
+                            <li><?php esc_html_e( 'PHP changes not taking effect? Reset OPcache or restart PHP — ask your host if unsure.', 'clusterlane' ); ?></li>
+                            <li><?php esc_html_e( 'The exact same error twice in a row after an update usually means an old cached script is still being served.', 'clusterlane' ); ?></li>
+                            <li><?php esc_html_e( 'AI calls failing? Use "Test connection" under Settings → AI, and check the API key row above.', 'clusterlane' ); ?></li>
+                            <li><?php esc_html_e( 'Graph empty or numbers stale? Click Scan in the bar at the top — it also rebuilds the clusters and recomputes every figure. Settings → Advanced has Recalculate clusters for a faster update without reading the articles.', 'clusterlane' ); ?></li>
+                            <li><?php esc_html_e( 'Contacting support? Download the report above and attach it — it contains no API keys or passwords.', 'clusterlane' ); ?></li>
+                        </ol>
+                    </div>
+                </div>
+            </div>
+
+            <textarea id="clusterlane-support-report" class="clusterlane-support-report" readonly aria-hidden="true" tabindex="-1"><?php echo esc_textarea( $text ); ?></textarea>
+        </div>
+
+        <?php \Clusterlane\Admin\Inline::start(); ?>
+        ( function () {
+            var report = document.getElementById( 'clusterlane-support-report' );
+            var copyBtn = document.getElementById( 'clusterlane-support-copy' );
+            var dlBtn   = document.getElementById( 'clusterlane-support-download' );
+            if ( ! report || ! copyBtn || ! dlBtn ) return;
+
+            function flash( btn, label ) {
+                var original = btn.innerHTML;
+                btn.textContent = label;
+                btn.disabled = true;
+                setTimeout( function () { btn.innerHTML = original; btn.disabled = false; }, 1600 );
+            }
+
+            copyBtn.addEventListener( 'click', function () {
+                var text = report.value;
+                if ( navigator.clipboard && window.isSecureContext ) {
+                    navigator.clipboard.writeText( text ).then(
+                        function () { flash( copyBtn, '✓ <?php echo esc_js( __( 'Copied', 'clusterlane' ) ); ?>' ); },
+                        function () { fallbackCopy( text ); }
+                    );
+                } else {
+                    fallbackCopy( text );
+                }
+            } );
+
+            function fallbackCopy( text ) {
+                report.style.display = 'block';
+                report.select();
+                try { document.execCommand( 'copy' ); flash( copyBtn, '✓ <?php echo esc_js( __( 'Copied', 'clusterlane' ) ); ?>' ); }
+                catch ( e ) { flash( copyBtn, '<?php echo esc_js( __( 'Copy failed', 'clusterlane' ) ); ?>' ); }
+                report.style.display = '';
+            }
+
+            dlBtn.addEventListener( 'click', function () {
+                var blob = new Blob( [ report.value ], { type: 'text/plain;charset=utf-8' } );
+                var url  = URL.createObjectURL( blob );
+                var a    = document.createElement( 'a' );
+                a.href = url;
+                a.download = dlBtn.dataset.filename || 'clusterlane-report.txt';
+                document.body.appendChild( a );
+                a.click();
+                a.remove();
+                setTimeout( function () { URL.revokeObjectURL( url ); }, 1000 );
+                flash( dlBtn, '✓ <?php echo esc_js( __( 'Downloaded', 'clusterlane' ) ); ?>' );
+            } );
+        } )();
+        <?php \Clusterlane\Admin\Inline::script(); ?>
+        <?php
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/admin/class-toolbar.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/admin/class-toolbar.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/admin/class-toolbar.php	(revision 3751027)
@@ -0,0 +1,92 @@
+<?php
+namespace Clusterlane\Admin;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Clusterlane in the WordPress toolbar (admin bar), 0.98.146.
+ *
+ * A "Clusterlane" item with its main pages, and — on an article's own page on
+ * the site, or while editing it — "Inspect this article", which opens it in
+ * Article Inspector. Only for users with Clusterlane access; turned off with
+ * Settings → General → "Show Clusterlane in the toolbar".
+ */
+final class Toolbar {
+
+    public static function register(): void {
+        add_action( 'admin_bar_menu', [ self::class, 'menu' ], 80 );
+        add_action( 'wp_enqueue_scripts', [ self::class, 'css' ] );
+        add_action( 'admin_enqueue_scripts', [ self::class, 'css' ] );
+    }
+
+    private static function on(): bool {
+        if ( ! is_user_logged_in() || ! is_admin_bar_showing() ) return false;
+        if ( ! \Clusterlane\Core\Capabilities::can_use() ) return false;
+        $s = \Clusterlane\Core\Settings::get_instance();
+        return $s ? (bool) $s->get( 'toolbar', true ) : true;
+    }
+
+    /** The article being viewed or edited, if Clusterlane analyses it. */
+    private static function current_article(): int {
+        $id = 0;
+        if ( is_admin() ) {
+            $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+            if ( $screen && $screen->base === 'post' && isset( $_GET['post'] ) ) $id = absint( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only
+        } elseif ( is_singular() ) {
+            $id = (int) get_queried_object_id();
+        }
+        if ( ! $id ) return 0;
+        $post = get_post( $id );
+        if ( \Clusterlane\Core\Scope::excludes( $post ) ) return 0;
+        return in_array( $post->post_type, \Clusterlane\Core\Scope::post_types(), true ) ? $id : 0;
+    }
+
+    public static function menu( \WP_Admin_Bar $bar ): void {
+        if ( ! self::on() ) return;
+        $url = static fn( string $page, array $args = [] ) => add_query_arg( array_merge( [ 'page' => $page ], $args ), admin_url( 'admin.php' ) );
+
+        $bar->add_node( [
+            'id'    => 'clusterlane',
+            'title' => '<span class="ab-icon clusterlane-ab-icon" aria-hidden="true"></span><span class="ab-label">' . esc_html( \Clusterlane\Core\Brand::name() ) . '</span>',
+            'href'  => $url( 'clusterlane-dashboard' ),
+            'meta'  => [ 'title' => \Clusterlane\Core\Brand::name() ],
+        ] );
+
+        $article = self::current_article();
+        if ( $article ) {
+            $bar->add_node( [
+                'parent' => 'clusterlane', 'id' => 'clusterlane-inspect',
+                'title'  => esc_html__( 'Inspect this article', 'clusterlane' ),
+                'href'   => $url( 'clusterlane-article-inspector', [ 'post_id' => $article ] ),
+                'meta'   => [ 'class' => 'clusterlane-ab-inspect' ],
+            ] );
+        }
+        $pages = [
+            'clusterlane-dashboard'         => __( 'Dashboard', 'clusterlane' ),
+            'clusterlane-cluster-inspector' => __( 'Cluster Inspector', 'clusterlane' ),
+            'clusterlane-article-inspector' => __( 'Article Inspector', 'clusterlane' ),
+            'clusterlane-seo-titles'        => __( 'SEO Titles', 'clusterlane' ),
+            'clusterlane-report'            => __( 'Report', 'clusterlane' ),
+        ];
+        foreach ( $pages as $page => $title ) {
+            $bar->add_node( [ 'parent' => 'clusterlane', 'id' => 'clusterlane-' . str_replace( 'clusterlane-', '', $page ) . '-ab', 'title' => esc_html( $title ), 'href' => $url( $page ) ] );
+        }
+    }
+
+    /** The mark as the item's icon, tinted like the toolbar's own icons. */
+    public static function css(): void {
+        if ( ! self::on() ) return;
+        $logo = \Clusterlane\Core\Brand::get()['logo_url'];
+        // Our mark is a data: URI (esc_url() would drop it); a custom logo is a URL.
+        $mask = $logo !== '' ? esc_url( $logo ) : esc_attr( \Clusterlane\Core\Brand::menu_icon() );
+        // Added to the toolbar's own stylesheet, so it loads wherever the toolbar does.
+        wp_add_inline_style( 'admin-bar', '#wpadminbar .clusterlane-ab-icon{width:20px;height:20px;margin-top:6px !important;padding:0 !important}'
+            . '#wpadminbar .clusterlane-ab-icon:before{content:"";display:block;width:20px;height:20px;background-color:currentColor;'
+            . '-webkit-mask:url("' . $mask . '") center/18px no-repeat;mask:url("' . $mask . '") center/18px no-repeat;opacity:.6}'
+            . '#wpadminbar #wp-admin-bar-clusterlane:hover .clusterlane-ab-icon:before{opacity:1}'
+            . '#wpadminbar .clusterlane-ab-inspect > .ab-item{font-weight:600}'
+            . '#wpadminbar .clusterlane-ab-inspect{border-bottom:1px solid rgba(255,255,255,.12);margin-bottom:4px !important;padding-bottom:4px !important}'
+            . '@media screen and (max-width:782px){#wpadminbar #wp-admin-bar-clusterlane{display:block}#wpadminbar #wp-admin-bar-clusterlane .ab-label{display:none}#wpadminbar .clusterlane-ab-icon{width:52px;height:46px;margin:0 !important}#wpadminbar .clusterlane-ab-icon:before{width:52px;height:46px;-webkit-mask-size:30px;mask-size:30px;opacity:.75}}'
+        );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-article-inspector.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-article-inspector.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-article-inspector.php	(revision 3751027)
@@ -0,0 +1,647 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * RestArticleInspector
+ *
+ * REST endpoints for the Article Inspector page.
+ *
+ * GET  clusterlane/v1/inspector/articles        — paged, filterable list ("Articles to review")
+ * GET  clusterlane/v1/inspector/search          — autocomplete post search
+ * GET  clusterlane/v1/inspector/post/{id}       — full article facts
+ * POST clusterlane/v1/inspector/tms-scan        — mini TMS scan for one post
+ */
+class RestArticleInspector {
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+
+        register_rest_route( 'clusterlane/v1', '/inspector/articles', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'list_articles' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/inspector/search', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'search_posts' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/inspector/post/(?P<id>\d+)', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'get_post_data' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+            'args'                => [
+                'id' => [ 'validate_callback' => fn( $v ) => is_numeric( $v ) ],
+            ],
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/inspector/tms-scan', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'tms_scan' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+    }
+
+    /* ------------------------------------------------------------------
+       Articles to review — paged list, "needs attention" first
+    ------------------------------------------------------------------ */
+
+    /**
+     * GET clusterlane/v1/inspector/articles
+     *   page, per_page (≤ 50)
+     *   q        — title contains
+     *   cluster  — cluster_key; role is then the cluster-relative role
+     *   role     — orphan | light | support | pillar
+     *   meta     — missing | weak (meta description) | title (SEO title too long) | ai (poor AI
+     *              review, 0.98.155) | dup (same title or description as another article); see SeoMeta
+     *   sort     — attention (default) | meta | title | score | inbound | outbound | words | scanned
+     *              (meta: missing description, then weak, then long title — SEO Titles' order)
+     *   seo      — 1: add each article's SEO title/description, URL and edit rights,
+     *              plus the page context (source, write policy, suffix) — SEO Titles, 0.98.154
+     *   order    — asc | desc (ignored for attention)
+     *
+     * Reads Clusterlane's own metric tables with one paged query, so it scales
+     * to large sites (no WP_Query over every post).
+     */
+    public function list_articles( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $settings = $this->plugin->get( 'settings' );
+
+        // export=1 (Export CSV): rows with URLs, no thumbnails, up to 1,000 per request —
+        // the browser fetches page after page (0.98.169; one request for everything ran
+        // into PHP's memory limit on very big sites).
+        $export   = (bool) $request->get_param( 'export' );
+        $per_page = $export ? min( 1000, max( 50, (int) ( $request->get_param( 'per_page' ) ?: 1000 ) ) ) : min( 50, max( 5, (int) ( $request->get_param( 'per_page' ) ?: 25 ) ) );
+        $page     = max( 1, (int) ( $request->get_param( 'page' ) ?: 1 ) );
+        $q        = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) );
+        // include=1,2,3 — only these IDs (used to prune "Recently inspected").
+        $include  = array_values( array_filter( array_map( 'absint', explode( ',', (string) $request->get_param( 'include' ) ) ) ) );
+        $cluster  = sanitize_key( (string) $request->get_param( 'cluster' ) );
+        $role     = sanitize_key( (string) $request->get_param( 'role' ) );
+        $meta     = sanitize_key( (string) $request->get_param( 'meta' ) );
+        $meta     = in_array( $meta, [ 'missing', 'weak', 'title', 'ai', 'dup' ], true ) ? $meta : '';
+        $with_seo = (bool) $request->get_param( 'seo' );
+        $sort     = sanitize_key( (string) ( $request->get_param( 'sort' ) ?: 'attention' ) );
+        $order    = strtolower( (string) $request->get_param( 'order' ) ) === 'asc' ? 'ASC' : 'DESC';
+
+        $m   = $wpdb->prefix . 'clusterlane_post_metrics';
+        $cpc = $wpdb->prefix . 'clusterlane_cluster_post_class';
+        $cl  = $wpdb->prefix . 'clusterlane_clusters';
+
+        $types = array_values( array_filter( array_map( 'sanitize_key', (array) ( $settings ? $settings->post_types() : [ 'post' ] ) ) ) ) ?: [ 'post' ];
+
+        /*
+         * The list is filtered, sorted and counted by several queries that share
+         * pieces. Each piece is [ SQL with placeholders, its values ]; $sql() joins
+         * pieces in order and every query goes through $wpdb->prepare() — request
+         * values never become part of the SQL text. What is written into the text
+         * is code: table aliases, whitelisted columns, Scope::sql() (no values) and
+         * the SeoMeta / Lang fragments, which prepare their own values.
+         */
+        $sql = static function ( array ...$parts ): array {
+            $s = '';
+            $a = [];
+            foreach ( $parts as [ $p, $v ] ) {
+                $s .= $p;
+                array_push( $a, ...$v );
+            }
+            return [ $s, $a ];
+        };
+        $and = static function ( array $conds ) use ( $sql ): array {
+            $out = [ '', [] ];
+            foreach ( array_values( $conds ) as $i => $c ) {
+                $out = $sql( $out, [ ( $i ? ' AND ' : '' ) . $c[0], $c[1] ] );
+            }
+            return $out;
+        };
+
+        // With a cluster filter the role is the role *in that cluster*; otherwise the site-wide class.
+        $join     = [ '', [] ];
+        $role_col = 'm.content_class';
+        $where    = [
+            [ \Clusterlane\Core\Scope::sql( 'p' ), [] ],
+            [ 'p.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', $types ],
+        ];
+        if ( $cluster !== '' ) {
+            $join     = [ ' INNER JOIN %i r ON r.post_id = m.post_id AND r.cluster_key = %s', [ $cpc, $cluster ] ];
+            $role_col = 'r.content_class';
+        }
+        if ( $q !== '' ) {
+            $where[] = [ 'p.post_title LIKE %s', [ '%' . $wpdb->esc_like( $q ) . '%' ] ];
+        }
+        // lang=<code> (0.98.168): multilingual sites — one language only.
+        $lang = sanitize_key( (string) $request->get_param( 'lang' ) );
+        if ( $lang !== '' && isset( \Clusterlane\Core\Lang::languages()[ $lang ] ) ) $where[] = [ \Clusterlane\Core\Lang::sql( $lang, 'p' ), [] ];
+        if ( $include ) {
+            $inc     = array_slice( $include, 0, 50 );
+            $where[] = [ 'm.post_id IN (' . implode( ',', array_fill( 0, count( $inc ), '%d' ) ) . ')', $inc ];
+        }
+        // Meta description (SEO plugin / custom field, excerpt fallback) — one LEFT JOIN.
+        $seo      = \Clusterlane\Core\SeoMeta::sql_description( 'p', 'm.post_id' );
+        // SEO title (only needed for the long-title filter/sort) — joined as seot.
+        $seot     = ( $with_seo || in_array( $meta, [ 'title', 'dup' ], true ) || $sort === 'meta' ) ? \Clusterlane\Core\SeoMeta::sql_title( 'p', 'm.post_id' ) : null;
+        $seo_join = [ ' ' . $seo['join'] . ( $seot ? ' ' . $seot['join'] : '' ), [] ];
+
+        // SEO Titles (0.98.155): the AI review's lower score (premium seo/check writes it) …
+        $chk = $with_seo || $meta === 'ai' || $sort === 'meta';
+        if ( $chk ) {
+            $seo_join = $sql( $seo_join, [
+                " LEFT JOIN ( SELECT post_id, MIN(CAST(meta_value AS UNSIGNED)) AS v, %d AS poor FROM {$wpdb->postmeta} WHERE meta_key = %s GROUP BY post_id ) chk ON chk.post_id = m.post_id",
+                [ (int) \Clusterlane\Core\SeoMeta::CHECK_POOR, \Clusterlane\Core\SeoMeta::CHECK_MIN ],
+            ] );
+        }
+        $ai_poor = 'chk.v <= chk.poor';
+        $from    = $sql( [ ' FROM %i m INNER JOIN %i p ON p.ID = m.post_id', [ $m, $wpdb->posts ] ], $join, $seo_join );
+
+        // … and titles/descriptions used by more than one article (site-wide, no AI).
+        $dups = [ 't' => [], 'd' => [] ];
+        $dup  = [ '0', [] ];
+        if ( $seot ) {
+            $dup_from  = $sql( [ ' FROM %i m INNER JOIN %i p ON p.ID = m.post_id', [ $m, $wpdb->posts ] ], $seo_join );
+            $dup_where = $and( array_slice( $where, 0, 2 ) ); // scope + post types
+            foreach ( [ 't' => $seot['expr'], 'd' => $seo['expr'] ] as $k => $expr ) {
+                $stmt     = $sql(
+                    [ "SELECT $expr AS dup_v, COUNT(*) AS dup_n", [] ], $dup_from, [ ' WHERE ', [] ], $dup_where,
+                    [ " AND CHAR_LENGTH($expr) > 0 GROUP BY dup_v HAVING dup_n > 1 ORDER BY dup_n DESC LIMIT 500", [] ]
+                );
+                $found = $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder
+                foreach ( (array) $found as $d ) {
+                    $dups[ $k ][ (string) $d->dup_v ] = (int) $d->dup_n;
+                }
+            }
+            $parts = [];
+            foreach ( [ 't' => $seot['expr'], 'd' => $seo['expr'] ] as $k => $expr ) {
+                if ( $dups[ $k ] ) {
+                    $vals    = array_map( 'strval', array_keys( $dups[ $k ] ) );
+                    $parts[] = [ "$expr IN (" . implode( ',', array_fill( 0, count( $vals ), '%s' ) ) . ')', $vals ];
+                }
+            }
+            if ( $parts ) {
+                $dup = [ '(', [] ];
+                foreach ( $parts as $i => $pt ) $dup = $sql( $dup, [ ( $i ? ' OR ' : '' ) . $pt[0], $pt[1] ] );
+                $dup = $sql( $dup, [ ')', [] ] );
+            }
+        }
+
+        $where_no_meta = $where;
+        if ( $meta === 'title' ) {
+            $where[] = [ \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ), [] ];
+        } elseif ( $meta === 'ai' ) {
+            $where[] = [ $ai_poor, [] ];
+        } elseif ( $meta === 'dup' ) {
+            $where[] = $dup;
+        } elseif ( $meta !== '' ) {
+            $where[] = [ \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], $meta ), [] ];
+        }
+        $where_no_role = $and( $where );
+        if ( in_array( $role, [ 'orphan', 'light', 'support', 'pillar' ], true ) ) {
+            $where[] = [ "$role_col = %s", [ $role ] ];
+        }
+        $where_all = $and( $where );
+
+        $sorts = [
+            'title'    => 'p.post_title',
+            'score'    => 'm.overall_score',
+            'inbound'  => 'm.inbound_links',
+            'outbound' => 'm.outbound_links',
+            'words'    => 'm.word_count',
+            'scanned'  => 'm.last_scanned',
+        ];
+        if ( $sort === 'meta' ) {
+            // SEO Titles: a poor AI review first (lowest score), then no description,
+            // a weak one, a long title, then A–Z.
+            $order_sql = "( $ai_poor ) DESC, chk.v ASC, ( " . \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], 'missing' ) . ' ) DESC, ( '
+                . \Clusterlane\Core\SeoMeta::sql_status( $seo['expr'], 'weak' ) . ' ) DESC, ( '
+                . \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ) . ' ) DESC, p.post_title ASC';
+        } else {
+            $order_sql = isset( $sorts[ $sort ] )
+                ? "{$sorts[ $sort ]} $order, p.post_title ASC"
+                // Needs attention: orphans, then lowest score, then fewest inbound links.
+                : "( $role_col = 'orphan' ) DESC, m.overall_score ASC, m.inbound_links ASC, p.post_title ASC";
+        }
+
+        $stmt      = $sql( [ 'SELECT COUNT(*)', [] ], $from, [ ' WHERE ', [] ], $where_all );
+        $total  = (int) $wpdb->get_var( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder
+        $pages  = max( 1, (int) ceil( $total / $per_page ) );
+        $page   = min( $page, $pages );
+        $offset = ( $page - 1 ) * $per_page;
+
+        $stmt    = $sql(
+            [ "SELECT m.post_id, p.post_title, p.post_type, $role_col AS role, m.overall_score, m.inbound_links, m.outbound_links,
+                    m.word_count, m.last_scanned, m.primary_cluster,
+                    ( SELECT c.cluster_label FROM %i c WHERE c.cluster_key = m.primary_cluster LIMIT 1 ) AS cluster_label,
+                    ( SELECT COUNT(*) FROM %i x WHERE x.post_id = m.post_id ) AS cluster_count" . ( $seot ? ", {$seot['expr']} AS seo_t_v, {$seo['expr']} AS seo_d_v" : '' ), [ $cl, $cpc ] ],
+            $from, [ ' WHERE ', [] ], $where_all,
+            [ " ORDER BY $order_sql LIMIT %d OFFSET %d", [ $per_page, $offset ] ]
+        );
+        $rows = $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder
+
+        // Role counts for the filter chips (same filters, any role).
+        $counts = [ 'orphan' => 0, 'light' => 0, 'support' => 0, 'pillar' => 0 ];
+        $stmt      = $sql( [ "SELECT $role_col AS role, COUNT(*) AS n", [] ], $from, [ ' WHERE ', [] ], $where_no_role, [ " GROUP BY $role_col", [] ] );
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( $stmt[0], $stmt[1] ) ) as $c ) { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder
+            if ( isset( $counts[ $c->role ] ) ) $counts[ $c->role ] = (int) $c->n;
+        }
+        // Meta chips: missing / weak for the same filters (role + search + cluster), any meta.
+        $where_meta = $where_no_meta;
+        if ( in_array( $role, [ 'orphan', 'light', 'support', 'pillar' ], true ) ) {
+            $where_meta[] = [ "$role_col = %s", [ $role ] ];
+        }
+        $e      = $seo['expr'];
+        $select = [ 'SELECT SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'missing' ) . ') AS missing, SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'weak' ) . ') AS weak', [] ];
+        if ( $seot ) {
+            $select = $sql( $select, [ ', SUM(' . \Clusterlane\Core\SeoMeta::sql_title_long( $seot['expr'] ) . ') AS title_long, SUM(', [] ], $dup, [ ') AS dup', [] ] );
+        }
+        if ( $chk ) {
+            $select = $sql( $select, [ ", SUM($ai_poor) AS ai_poor, SUM(chk.v IS NOT NULL) AS ai_checked", [] ] );
+        }
+        $stmt  = $sql( $select, $from, [ ' WHERE ', [] ], $and( $where_meta ) );
+        $mc = $wpdb->get_row( $wpdb->prepare( $stmt[0], $stmt[1] ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- built from code-only fragments; every value is a placeholder
+
+        $items = array_map( fn( $r ) => [
+            'id'            => (int) $r->post_id,
+            'title'         => html_entity_decode( get_the_title( (int) $r->post_id ) ?: $r->post_title, ENT_QUOTES, 'UTF-8' ),
+            'role'          => (string) $r->role,
+            'score'         => (int) $r->overall_score,
+            'inbound'       => (int) $r->inbound_links,
+            'outbound'      => (int) $r->outbound_links,
+            'words'         => (int) $r->word_count,
+            'last_scanned'  => (string) $r->last_scanned,
+            'cluster_label' => (string) ( $r->cluster_label ?? '' ),
+            'cluster_count' => (int) $r->cluster_count,
+            'type'          => (string) $r->post_type, // 'product' on WooCommerce shops (0.98.172)
+        ] + ( $seot ? [
+            // Other articles with the very same SEO title / description (0 = unique).
+            'dup_title' => max( 0, ( $dups['t'][ (string) $r->seo_t_v ] ?? 1 ) - 1 ),
+            'dup_desc'  => (string) $r->seo_d_v === '' ? 0 : max( 0, ( $dups['d'][ (string) $r->seo_d_v ] ?? 1 ) - 1 ),
+        ] : [] ), (array) $rows );
+        if ( \Clusterlane\Core\Lang::active() ) {
+            $langs = \Clusterlane\Core\Lang::map( array_column( $items, 'id' ) );
+            foreach ( $items as &$it ) $it['lang'] = $langs[ $it['id'] ] ?? '';
+            unset( $it );
+        }
+        if ( $export ) {
+            foreach ( $items as &$it ) $it['url'] = (string) get_permalink( $it['id'] );
+            unset( $it );
+        } else {
+            $items = \Clusterlane\Core\Thumbs::add( $items );
+        }
+        if ( $with_seo ) {
+            foreach ( $items as &$it ) {
+                $post = get_post( $it['id'] );
+                if ( ! $post ) continue;
+                $it['seo']      = \Clusterlane\Core\SeoMeta::get( $post ) + [ 'original' => \Clusterlane\Core\SeoMeta::original_state( $post->ID ) ];
+                $it['url']      = (string) get_permalink( $post );
+                $it['edit_url'] = current_user_can( 'edit_post', $post->ID ) ? (string) get_edit_post_link( $post->ID, 'raw' ) : '';
+                $it['can_edit'] = current_user_can( 'edit_post', $post->ID );
+                $it['undo']     = (bool) get_transient( 'clusterlane_seo_undo_' . $post->ID );
+                $it['check']    = \Clusterlane\Core\SeoMeta::check( $post->ID ); // AI review, if any (Pro adds 'stale')
+            }
+            unset( $it );
+            /** Premium: each row's cached AI suggestions (key 'ai', null when none are current). */
+            $items = (array) apply_filters( 'clusterlane_seo_list_items', $items );
+        }
+
+        return rest_ensure_response( [
+            'items'    => $items,
+            'total'    => $total,
+            'page'     => $page,
+            'pages'    => $pages,
+            'per_page' => $per_page,
+            'counts'   => $counts,
+            'meta_counts' => [ 'missing' => (int) ( $mc->missing ?? 0 ), 'weak' => (int) ( $mc->weak ?? 0 ), 'title' => (int) ( $mc->title_long ?? 0 ),
+                               'dup' => (int) ( $mc->dup ?? 0 ), 'ai' => (int) ( $mc->ai_poor ?? 0 ), 'ai_checked' => (int) ( $mc->ai_checked ?? 0 ) ],
+            'scope'    => $cluster !== '' ? 'cluster' : 'site',
+            // Multilingual sites: code → native name, for the language filter (empty otherwise).
+            'languages' => \Clusterlane\Core\Lang::active() ? array_map( fn( $l ) => $l['name'], \Clusterlane\Core\Lang::languages() ) : (object) [],
+        ] + ( $with_seo ? [ 'seo_ctx' => self::seo_context() ] : [] ) );
+    }
+
+    /** SEO Titles: where the fields live, whether Clusterlane may write them, and the limits. */
+    public static function seo_context(): array {
+        $src       = \Clusterlane\Core\SeoMeta::source();
+        $writable  = \Clusterlane\Core\SeoMeta::can_write();
+        $may       = \Clusterlane\Core\Capabilities::can_write_seo();
+        return [
+            'source'     => $src['label'],
+            'source_id'  => $src['id'],
+            'fields'     => [ 'title' => $src['title'], 'description' => $src['desc'] ],
+            'can_write'  => $writable && $may,
+            'write_note' => $writable && ! $may ? \Clusterlane\Core\Capabilities::no_right_message( \Clusterlane\Core\Capabilities::SEO ) : \Clusterlane\Core\SeoMeta::write_note(),
+            'fallback'   => \Clusterlane\Core\SeoMeta::fallback(),
+            'suffix'     => \Clusterlane\Core\SeoMeta::suffix(),
+            'title_max'  => \Clusterlane\Core\SeoMeta::TITLE_MAX,
+            'desc_min'   => \Clusterlane\Core\SeoMeta::DESC_MIN,
+            'desc_max'   => \Clusterlane\Core\SeoMeta::DESC_MAX,
+            'undo_min'   => (int) ( \Clusterlane\Api\RestSeo::UNDO_TTL / MINUTE_IN_SECONDS ),
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       Search — autocomplete
+    ------------------------------------------------------------------ */
+
+    public function search_posts( \WP_REST_Request $request ): \WP_REST_Response {
+        $q = sanitize_text_field( $request->get_param( 'q' ) ?? '' );
+        if ( strlen( $q ) < 2 ) {
+            return rest_ensure_response( [] );
+        }
+
+        $posts = get_posts( \Clusterlane\Core\Scope::query_args( [
+            'post_type'      => \Clusterlane\Core\Scope::post_types(),
+            'post_status'    => 'publish',
+            's'              => $q,
+            'posts_per_page' => 10,
+            'orderby'        => 'relevance',
+        ] ) );
+
+        $results = \Clusterlane\Core\Thumbs::add( array_map( fn( $p ) => [
+            'id'    => $p->ID,
+            'title' => $p->post_title,
+            'url'   => get_permalink( $p->ID ),
+        ], $posts ) );
+
+        return rest_ensure_response( $results );
+    }
+
+    /* ------------------------------------------------------------------
+       Full post data
+    ------------------------------------------------------------------ */
+
+    public function get_post_data( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+
+        $post_id = (int) $request->get_param( 'id' );
+        $post    = get_post( $post_id );
+
+        // Same scope as everything else (published, not password-protected).
+        if ( \Clusterlane\Core\Scope::excludes( $post ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'This article is not analysed by Clusterlane (unpublished or password-protected).', 'clusterlane' ), 'code' => 'clusterlane_out_of_scope' ], 404 );
+        }
+
+        // --- Basic post info ---
+        // get_the_category() / get_the_tags() store names with HTML entities
+        // pre-encoded (e.g. "Art &amp; Design Museums") — decode once so the
+        // REST response holds raw text that JS can safely escape itself.
+        $categories = get_the_category( $post_id );
+        $tags       = get_the_tags( $post_id ) ?: [];
+        $decode     = fn( string $s ) => html_entity_decode( $s, ENT_QUOTES, 'UTF-8' );
+
+        // --- Metrics from DB ---
+        $metrics = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d",
+            $post_id
+        ) );
+
+        // --- Clusters this post belongs to ---
+        // Build cluster_key lists in PHP using WP's own term functions,
+        // then do a simple IN-query — avoids fragile SUBSTRING_INDEX SQL.
+
+        // Its terms in every taxonomy that has clusters, plus custom clusters (0.98.177:
+        // product categories and other taxonomies too, not only categories and tags).
+        $all_keys = \Clusterlane\Core\ClusterTypes::post_keys( $post_id );
+
+        $clusters = [];
+        if ( ! empty( $all_keys ) ) {
+            $all_keys = array_values( $all_keys );
+            $clusters = $wpdb->get_results(
+                $wpdb->prepare(
+                    "SELECT c.cluster_key, c.cluster_label, c.cluster_type,
+                            c.health_score, c.archetype,
+                            cpc.content_class, cpc.inbound_links AS cluster_inbound, cpc.percentile
+                     FROM {$wpdb->prefix}clusterlane_clusters c
+                     LEFT JOIN {$wpdb->prefix}clusterlane_cluster_post_class cpc
+                            ON cpc.cluster_key = c.cluster_key AND cpc.post_id = %d
+                     WHERE c.is_active = 1
+                       AND c.cluster_key IN (" . implode( ',', array_fill( 0, count( $all_keys ), '%s' ) ) . ")
+                     ORDER BY c.cluster_label ASC",
+                    array_merge( [ $post_id ], $all_keys )
+                )
+            );
+        }
+
+        // --- Cluster members (for ego graph) ---
+        // For each cluster this post belongs to, fetch sibling members
+        // so the graph can show the full neighbourhood.
+        $cluster_members = [];
+        if ( ! empty( $all_keys ) ) {
+            foreach ( $all_keys as $ck ) {
+                // Get up to 30 members per cluster from clusterlane_cluster_post_class.
+                $members = $wpdb->get_results( $wpdb->prepare(
+                    "SELECT cpc.post_id, p.post_title, cpc.content_class
+                     FROM {$wpdb->prefix}clusterlane_cluster_post_class cpc
+                     INNER JOIN {$wpdb->posts} p ON p.ID = cpc.post_id
+                     WHERE cpc.cluster_key = %s AND cpc.post_id != %d
+                     ORDER BY cpc.inbound_links DESC
+                     LIMIT 30",
+                    $ck, $post_id
+                ) );
+                $cluster_members[ $ck ] = array_map( fn( $m ) => [
+                    'id'            => (int) $m->post_id,
+                    'title'         => $m->post_title,
+                    'url'           => get_permalink( (int) $m->post_id ),
+                    'content_class' => $m->content_class,
+                ], $members );
+            }
+        }
+
+        // --- Inbound links (posts that link TO this one) ---
+        $inbound_links = $wpdb->get_results( $wpdb->prepare(
+            "SELECT l.source_id, l.link_type, p.post_title, p.guid
+             FROM {$wpdb->prefix}clusterlane_links l
+             INNER JOIN {$wpdb->posts} p ON p.ID = l.source_id
+             WHERE l.target_id = %d AND l.target_type = 'post'
+             ORDER BY p.post_title ASC
+             LIMIT 50",
+            $post_id
+        ) );
+
+        // --- Outbound links (posts this one links TO) ---
+        $outbound_links = $wpdb->get_results( $wpdb->prepare(
+            "SELECT l.target_id, l.link_type, p.post_title, p.guid
+             FROM {$wpdb->prefix}clusterlane_links l
+             INNER JOIN {$wpdb->posts} p ON p.ID = l.target_id
+             WHERE l.source_id = %d AND l.target_type = 'post'
+             ORDER BY p.post_title ASC
+             LIMIT 50",
+            $post_id
+        ) );
+
+        // Links to category/tag pages: counted in the scan's outbound total but
+        // not articles — listed separately so the two numbers always agree.
+        // (target_type matters: term IDs and post IDs are separate sequences.)
+        $outbound_terms = [];
+        foreach ( (array) $wpdb->get_col( $wpdb->prepare(
+            "SELECT DISTINCT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'term'", $post_id
+        ) ) as $tid ) {
+            $t = get_term( (int) $tid );
+            if ( ! $t || is_wp_error( $t ) ) continue;
+            $link = get_term_link( $t );
+            $outbound_terms[] = [ 'id' => (int) $t->term_id, 'name' => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), 'taxonomy' => $t->taxonomy, 'url' => is_wp_error( $link ) ? '' : $link ];
+        }
+        $link_counts = [
+            'in'        => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT source_id) FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = 'post'", $post_id ) ),
+            'out_posts' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT target_id) FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post'", $post_id ) ),
+            'out_terms' => count( $outbound_terms ),
+        ];
+
+        return rest_ensure_response( [
+            'post' => [
+                'id'                 => $post->ID,
+                'title'              => $post->post_title,
+                'url'                => get_permalink( $post->ID ),
+                'edit_url'           => get_edit_post_link( $post->ID, 'raw' ) ?? admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),
+                'date'               => get_the_date( 'Y-m-d', $post ),
+                'modified'           => get_the_modified_date( 'Y-m-d', $post ),
+                'categories'         => array_map( fn( $c ) => [ 'id' => $c->term_id, 'name' => $decode( $c->name ), 'taxonomy' => 'category' ], $categories ),
+                'tags'               => array_map( fn( $t ) => [ 'id' => $t->term_id, 'name' => $decode( $t->name ), 'taxonomy' => 'post_tag' ], $tags ),
+                'thumb'              => \Clusterlane\Core\Thumbs::url( $post->ID ),
+                // WooCommerce (0.98.172): a product gets product-page advice, not article advice.
+                'is_product'         => \Clusterlane\Core\Shop::is_product( $post ),
+                'short_desc_words'   => \Clusterlane\Core\Shop::is_product( $post ) ? str_word_count( \Clusterlane\Core\Shop::short_description( $post ), 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) : 0,
+                'featured_image_url' => has_post_thumbnail( $post->ID )
+                                        ? get_the_post_thumbnail_url( $post->ID, 'large' )
+                                        : null,
+            ],
+            // SEO title + meta description as search engines get them (SeoMeta).
+            'seo' => \Clusterlane\Core\SeoMeta::get( $post ) + [
+                // The saved AI review, if any (0.98.196) — read only, never an AI call.
+                'check'     => self::seo_check( $post ),
+                'original'  => \Clusterlane\Core\SeoMeta::original_state( $post->ID ), // 0.98.197
+                'title_max' => \Clusterlane\Core\SeoMeta::TITLE_MAX,
+                'desc_min'  => \Clusterlane\Core\SeoMeta::DESC_MIN,
+                'desc_max'  => \Clusterlane\Core\SeoMeta::DESC_MAX,
+            ],
+            'metrics' => $metrics ? [
+                'word_count'     => (int) $metrics->word_count,
+                'image_count'    => (int) $metrics->image_count,
+                'h2_count'       => (int) $metrics->h2_count,
+                'inbound_links'  => (int) $metrics->inbound_links,
+                'outbound_links' => (int) $metrics->outbound_links,
+                'overall_score'  => (int) $metrics->overall_score,
+                'seo_score'      => (int) $metrics->seo_score,
+                'structure_score'=> (int) $metrics->structure_score,
+                'content_class'  => $metrics->content_class,
+                'last_scanned'   => $metrics->last_scanned,
+            ] : null,
+            'clusters'       => array_map( fn( $c ) => [
+                'key'            => $c->cluster_key,
+                'label'          => $c->cluster_label,
+                'type'           => $c->cluster_type,
+                'health_score'   => (int) $c->health_score,
+                'archetype'      => $c->archetype,
+                'content_class'  => $c->content_class,
+                'percentile'     => $c->percentile !== null ? (int) $c->percentile : null,
+            ], $clusters ),
+            'cluster_members' => array_map( fn( $list ) => \Clusterlane\Core\Thumbs::add( $list ), $cluster_members ),
+            'inbound_links'  => \Clusterlane\Core\Thumbs::add( array_map( fn( $l ) => [
+                'id'    => (int) $l->source_id,
+                'title' => $l->post_title,
+                'url'   => get_permalink( (int) $l->source_id ),
+                'auto'     => $l->link_type === 'auto', // pillar box (Pro), not in the text
+                'template' => $l->link_type === 'template', // theme list or card on the published page (0.98.177)
+            ], $inbound_links ) ),
+            'outbound_terms' => $outbound_terms,
+            'link_counts'    => $link_counts,
+            'outbound_links' => \Clusterlane\Core\Thumbs::add( array_map( fn( $l ) => [
+                'id'    => (int) $l->target_id,
+                'title' => $l->post_title,
+                'url'   => get_permalink( (int) $l->target_id ),
+                'auto'     => $l->link_type === 'auto',
+                'template' => $l->link_type === 'template',
+            ], $outbound_links ) ),
+        ] );
+    }
+
+    /* ------------------------------------------------------------------
+       Mini TMS scan — scan ONE post for link opportunities
+    ------------------------------------------------------------------ */
+
+    public function tms_scan( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+
+        $params  = $request->get_json_params();
+        $post_id = (int) ( $params['post_id'] ?? 0 );
+
+        if ( ! $post_id ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing post_id' ], 400 );
+        }
+
+        $tm       = $this->plugin->get( 'text_match' );
+        $settings = $this->plugin->get( 'settings' );
+
+        if ( $settings ) {
+            $tm->set_extra_stop_words( $settings->tms_stop_words() );
+        }
+
+        // Load skipped pairs for this post.
+        $skipped_table   = $wpdb->prefix . 'clusterlane_tms_skipped';
+        $skipped_targets = array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
+            'SELECT target_id FROM %i WHERE source_id = %d',
+            $skipped_table, $post_id
+        ) ) );
+
+        // Run scan — all candidates, no offset, full pass.
+        $result  = $tm->scan( $post_id, PHP_INT_MAX, 0 ); // every article (was the first 500 only)
+        $matches = array_values( array_filter(
+            $result['matches'],
+            fn( $m ) => ! in_array( (int) $m['target_id'], $skipped_targets, true )
+        ) );
+
+        // Other articles that mention THIS article's title without linking here
+        // (same keyword rules as the forward scan; skipped pairs left out).
+        $reverse = array_values( array_filter(
+            $tm->scan_for_target( $post_id, 30 ),
+            fn( $m ) => ! $wpdb->get_var( $wpdb->prepare(
+                'SELECT 1 FROM %i WHERE source_id = %d AND target_id = %d', $skipped_table, (int) $m['source_id'], $post_id
+            ) )
+        ) );
+
+        // Role + inbound of each outgoing target: orphans first in the list.
+        if ( $matches ) {
+            $ids = array_values( array_map( fn( $m ) => (int) $m['target_id'], $matches ) );
+            $met = [];
+            foreach ( (array) $wpdb->get_results( $wpdb->prepare(
+                "SELECT post_id, content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids
+            ) ) as $r ) { $met[ (int) $r->post_id ] = $r; }
+            foreach ( $matches as &$m ) {
+                $r = $met[ (int) $m['target_id'] ] ?? null;
+                $m['target_role']    = $r ? (string) $r->content_class : '';
+                $m['target_inbound'] = $r ? (int) $r->inbound_links : 0;
+            }
+            unset( $m );
+        }
+
+        // Thumbnail of "the other article" in each row (target when this article
+        // links out, source when another article should link here).
+        $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' );
+        $reverse = \Clusterlane\Core\Thumbs::add( $reverse, 'source_id', 'source_thumb' );
+
+        return rest_ensure_response( [
+            'post_id'          => $post_id,
+            'outgoing_matches' => $matches,   // this post mentions other titles without linking
+            'reverse_matches'  => $reverse,   // other posts mention this post's title without linking here
+            'total_candidates' => $result['total_candidates'],
+        ] );
+    }
+
+    /** The stored AI review of the SEO title and description; Pro marks it stale (filter clusterlane_seo_check_item). */
+    private static function seo_check( \WP_Post $post ): ?array {
+        $c = \Clusterlane\Core\SeoMeta::check( $post->ID );
+        if ( ! $c ) return null;
+        $c = (array) apply_filters( 'clusterlane_seo_check_item', $c, $post );
+        unset( $c['hash'] );
+        return $c;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-cluster-inspector.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-cluster-inspector.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-cluster-inspector.php	(revision 3751027)
@@ -0,0 +1,602 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * RestClusterInspector
+ *
+ * Endpoints for the Cluster Inspector page:
+ *   GET  clusterlane/v1/cluster-inspector/overview   — hero stats + cross-cluster flow graph
+ *   GET  clusterlane/v1/cluster-inspector/detail/{key} — full cluster detail (members, links, gap)
+ */
+class RestClusterInspector {
+
+    /** Most cluster-to-cluster lines the flow map gets (0.98.171). */
+    public const MAX_FLOW_EDGES = 800;
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/overview', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'overview' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/detail/(?P<key>[a-z0-9_\-]+)', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'detail' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Quick view of one cluster (0.98.200) — Report and the Cluster Inspector list.
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/quick/(?P<key>[a-z0-9_\-]+)', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'quick' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Choose (or clear with post_id 0) the cluster's pillar — structure, so admins only.
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/pillar', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'set_pillar' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+        // The section a cluster is listed under (0.98.160); '' = automatic.
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/section', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'set_section' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+        register_rest_route( 'clusterlane/v1', '/cluster-inspector/section-group', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'edit_group' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+    }
+
+    /** The editor's own groups (0.98.161): {action: add|rename|delete|move, key?, label?, dir?, assign?} */
+    public function edit_group( \WP_REST_Request $request ): \WP_REST_Response {
+        $p   = (array) $request->get_json_params();
+        $key = \Clusterlane\Core\ClusterSections::edit_group( sanitize_key( (string) ( $p['action'] ?? '' ) ), sanitize_key( (string) ( $p['key'] ?? '' ) ), (string) ( $p['label'] ?? '' ), (int) ( $p['dir'] ?? 0 ) );
+        if ( $key === null ) return new \WP_REST_Response( [ 'error' => __( 'Could not change the group.', 'clusterlane' ) ], 400 );
+        $assign = sanitize_key( (string) ( $p['assign'] ?? '' ) );
+        if ( $assign !== '' && ( $p['action'] ?? '' ) === 'add' ) \Clusterlane\Core\ClusterSections::set( $assign, $key );
+        return rest_ensure_response( [ 'ok' => true, 'group' => $key ] + $this->sections() );
+    }
+
+    public function set_section( \WP_REST_Request $request ): \WP_REST_Response {
+        $p   = (array) $request->get_json_params();
+        $key = sanitize_key( (string) ( $p['key'] ?? '' ) );
+        if ( $key === '' ) return new \WP_REST_Response( [ 'error' => __( 'Cluster not found.', 'clusterlane' ) ], 404 );
+        \Clusterlane\Core\ClusterSections::set( $key, sanitize_key( (string) ( $p['section'] ?? '' ) ) );
+        return rest_ensure_response( [ 'ok' => true ] + $this->sections() );
+    }
+
+    /** Sections for the cluster list (ClusterSections). */
+    private function sections( ?array $clusters = null, ?array $members = null ): array {
+        $engine = $this->plugin->get( 'cluster_engine' );
+        if ( $clusters === null ) $clusters = $engine ? $engine->get_clusters() : [];
+        if ( $members === null ) {
+            $members = [];
+            foreach ( $clusters as $c ) $members[ $c->cluster_key ] = $engine->get_member_post_ids( $c );
+        }
+        $r = \Clusterlane\Core\ClusterSections::compute( $clusters, $members );
+        return [ 'sections' => $r['sections'], 'section_map' => $r['map'], 'section_choices' => $r['choices'], 'section_menu' => $r['menu'] ];
+    }
+
+    public function set_pillar( \WP_REST_Request $request ): \WP_REST_Response {
+        $p       = (array) $request->get_json_params();
+        $key     = sanitize_key( (string) ( $p['key'] ?? '' ) );
+        $post_id = (int) ( $p['post_id'] ?? 0 );
+        $engine  = $this->plugin->get( 'cluster_engine' );
+        global $wpdb;
+        $cluster = $key ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) ) : null;
+        if ( ! $cluster || ! $engine ) {
+            return new \WP_REST_Response( [ 'error' => __( 'Cluster not found.', 'clusterlane' ) ], 404 );
+        }
+        if ( $post_id > 0 && ! in_array( $post_id, array_map( 'intval', $engine->get_member_post_ids( $cluster ) ), true ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'That article is not in this cluster.', 'clusterlane' ) ], 400 );
+        }
+        $previous = \Clusterlane\Core\ClusterEngine::chosen_pillar( $key );
+        \Clusterlane\Core\ClusterEngine::set_chosen_pillar( $key, max( 0, $post_id ) );
+        /** Pro: the pillar link box points the cluster's articles at the new pillar. */
+        do_action( 'clusterlane_pillar_changed', $key, $previous, max( 0, $post_id ) );
+        $engine->compute_cluster( $key ); // roles update right away
+        if ( $post_id > 0 ) {
+            \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::PILLAR_CHOSEN, 'cluster_inspector', $post_id, 0, '', $key );
+        } elseif ( $previous > 0 ) {
+            \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::PILLAR_CLEARED, 'cluster_inspector', $previous, 0, '', $key );
+        }
+        /** Pro adds what the pillar link box did (pillar_box: on, articles). */
+        $resp = apply_filters( 'clusterlane_pillar_set_response', [ 'ok' => true, 'key' => $key, 'chosen_pillar' => max( 0, $post_id ), 'previous' => $previous ], $key, max( 0, $post_id ) );
+        return rest_ensure_response( $resp );
+    }
+
+    /* ------------------------------------------------------------------
+       Overview — hero stats + cross-cluster flow graph
+    ------------------------------------------------------------------ */
+
+    public function overview(): \WP_REST_Response {
+        global $wpdb;
+
+        $engine   = $this->plugin->get( 'cluster_engine' );
+        $clusters = $engine ? $engine->get_clusters() : [];
+
+        if ( empty( $clusters ) ) {
+            return rest_ensure_response( [
+                'stats'    => [],
+                'clusters' => [],
+                'nodes'    => [],
+                'edges'    => [],
+            ] );
+        }
+
+        // Role counts per cluster (orphans / pillars / …) for the cluster table.
+        $role_counts = [];
+        foreach ( (array) $wpdb->get_results(
+            "SELECT cluster_key, content_class, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class GROUP BY cluster_key, content_class" // phpcs:ignore
+        ) as $rc ) {
+            $role_counts[ $rc->cluster_key ][ $rc->content_class ] = (int) $rc->n;
+        }
+        foreach ( $clusters as $c ) {
+            $rcs         = $role_counts[ $c->cluster_key ] ?? [];
+            $c->orphans  = (int) ( $rcs['orphan']  ?? 0 );
+            $c->pillars  = (int) ( $rcs['pillar']  ?? 0 );
+            $c->supports = (int) ( $rcs['support'] ?? 0 );
+            $c->lights   = (int) ( $rcs['light']   ?? 0 );
+        }
+
+        // --- Hero stats ---
+        $total_clusters  = count( $clusters );
+        $total_posts     = (int) $wpdb->get_var(
+            "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->prefix}clusterlane_post_metrics"
+        );
+        // Weighted by articles — the same figure as the Dashboard ring, snapshots and reports
+        // (0.98.164; this used to be a plain average of the clusters). Empty clusters weigh nothing.
+        $avg_health      = \Clusterlane\Core\ClusterEngine::weighted_health( $clusters );
+        $orphan_count    = (int) $wpdb->get_var(
+            "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_post_metrics WHERE content_class = 'orphan'"
+        );
+        $pillar_count    = (int) $wpdb->get_var(
+            "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_post_metrics WHERE content_class = 'pillar'"
+        );
+
+        // --- Cross-cluster link flow graph ---
+        // Build a map: cluster_key → member post IDs.
+        $cluster_member_ids = [];
+        foreach ( $clusters as $c ) {
+            $ids = $engine->get_member_post_ids( $c );
+            if ( ! empty( $ids ) ) {
+                $cluster_member_ids[ $c->cluster_key ] = $ids;
+            }
+        }
+
+        // Build a reverse map: post_id → [cluster_keys].
+        $post_cluster_map = [];
+        foreach ( $cluster_member_ids as $key => $ids ) {
+            foreach ( $ids as $id ) {
+                $post_cluster_map[ $id ][] = $key;
+            }
+        }
+
+        // Get all inter-cluster links: source post in cluster A → target post in cluster B (A ≠ B).
+        // Flow weight = number of such links.
+        $all_ids = array_unique( array_merge( ...array_values( $cluster_member_ids ) ) );
+        if ( empty( $all_ids ) ) {
+            return rest_ensure_response( [
+                'stats'    => compact( 'total_clusters', 'total_posts', 'avg_health', 'orphan_count', 'pillar_count' ),
+                'clusters' => $clusters,
+                'nodes'    => [],
+                'edges'    => [],
+            ] + $this->sections( $clusters, $cluster_member_ids ) );
+        }
+
+        $all_ids         = array_values( array_map( 'intval', $all_ids ) );
+        $links           = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_links
+                 WHERE source_id IN (" . implode( ',', array_fill( 0, count( $all_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $all_ids ), '%d' ) ) . ")
+                 AND target_type = 'post'",
+                array_merge( $all_ids, $all_ids )
+            )
+        );
+
+        // Aggregate flow: "cluster_a→cluster_b" => count.
+        $flow = [];
+        foreach ( $links as $link ) {
+            $src_clusters = $post_cluster_map[ (int) $link->source_id ] ?? [];
+            $tgt_clusters = $post_cluster_map[ (int) $link->target_id ] ?? [];
+            foreach ( $src_clusters as $src_key ) {
+                foreach ( $tgt_clusters as $tgt_key ) {
+                    if ( $src_key === $tgt_key ) continue; // same cluster — skip
+                    $edge_key = $src_key . '→' . $tgt_key;
+                    $flow[ $edge_key ] = ( $flow[ $edge_key ] ?? 0 ) + 1;
+                }
+            }
+        }
+
+        // Build vis-network nodes (one per cluster).
+        $health_palette = function( int $h ): array {
+            if ( $h >= 70 ) return [ 'bg' => '#dcfce7', 'border' => '#16a34a' ];
+            if ( $h >= 50 ) return [ 'bg' => '#fef9c3', 'border' => '#ca8a04' ];
+            return                 [ 'bg' => '#fee2e2', 'border' => '#dc2626' ];
+        };
+
+        $nodes = [];
+        foreach ( $clusters as $c ) {
+            $pal     = $health_palette( (int) $c->health_score );
+            $members = count( $cluster_member_ids[ $c->cluster_key ] ?? [] );
+            $size    = max( 12, min( 50, 12 + $members * 2 ) );
+            $nodes[] = [
+                'id'          => $c->cluster_key,
+                'label'       => mb_strlen( $c->cluster_label ) > 24
+                                 ? mb_substr( $c->cluster_label, 0, 22 ) . '…'
+                                 : $c->cluster_label,
+                'title'       => $c->cluster_label . "\nHealth: {$c->health_score}% · Members: $members\nArchetype: " . ( $c->archetype ?: 'Unclassified' ),
+                'value'       => $size,
+                'size'        => $size,
+                'color'       => [ 'background' => $pal['bg'], 'border' => $pal['border'],
+                                   'highlight'  => [ 'background' => $pal['bg'], 'border' => '#1e293b' ] ],
+                'font'        => [ 'color' => '#1e293b', 'size' => 11 ],
+                'health'      => (int) $c->health_score,
+                'members'     => $members,
+                'archetype'   => $c->archetype ?: 'Unclassified',
+                'cluster_type'=> $c->cluster_type,
+            ];
+        }
+
+        // Many clusters (0.98.171): every pair of clusters with a single link between
+        // them became a line — 551 clusters gave 129,608 lines, a 33 MB response and
+        // 267 MB of PHP memory. Keep the strongest MAX_FLOW_EDGES, plus each cluster's
+        // own strongest link so no cluster looks cut off; say how many there are.
+        $edges_total = count( $flow );
+        if ( $edges_total > self::MAX_FLOW_EDGES ) {
+            arsort( $flow );
+            $keep = array_slice( $flow, 0, self::MAX_FLOW_EDGES, true );
+            $seen = [];
+            foreach ( $keep as $k => $v ) $seen[ strstr( $k, '→', true ) ] = true;
+            foreach ( $flow as $k => $v ) {
+                $src = strstr( $k, '→', true );
+                if ( isset( $seen[ $src ] ) ) continue;
+                $keep[ $k ] = $v; $seen[ $src ] = true; // this cluster's strongest link (sorted, so first seen)
+            }
+            $flow = $keep;
+        }
+
+        // Build vis-network edges.
+        $edges = [];
+        $max_flow = $flow ? max( array_values( $flow ) ) : 1;
+        foreach ( $flow as $edge_key => $count ) {
+            [ $src_key, $tgt_key ] = explode( '→', $edge_key );
+            $width   = max( 1, min( 8, round( ( $count / $max_flow ) * 8 ) ) );
+            $opacity = max( 0.2, min( 0.9, 0.2 + ( $count / $max_flow ) * 0.7 ) );
+            $alpha   = dechex( (int) round( $opacity * 255 ) );
+            $edges[] = [
+                'from'   => $src_key,
+                'to'     => $tgt_key,
+                'value'  => $count,
+                'width'  => $width,
+                'title'  => "$count link(s) from this cluster to the other",
+                'color'  => [ 'color' => '#3b82f6' . str_pad( $alpha, 2, '0', STR_PAD_LEFT ),
+                               'highlight' => '#2563eb' ],
+                'arrows' => [ 'to' => [ 'enabled' => true, 'scaleFactor' => 0.5 ] ],
+                'smooth' => [ 'type' => 'curvedCW', 'roundness' => 0.2 ],
+            ];
+        }
+
+        return rest_ensure_response( [
+            'stats' => compact( 'total_clusters', 'total_posts', 'avg_health', 'orphan_count', 'pillar_count' ),
+            'clusters' => $clusters,
+            'nodes'    => $nodes,
+            'edges'    => $edges,
+            'edges_total' => $edges_total, // all cluster-to-cluster connections (edges may be fewer)
+        ] + $this->sections( $clusters, $cluster_member_ids ) );
+    }
+
+    /* ------------------------------------------------------------------
+       Detail — full cluster data for inline expand
+    ------------------------------------------------------------------ */
+
+    public function detail( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+
+        $key     = sanitize_key( $request['key'] );
+        $cluster = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s AND is_active = 1",
+            $key
+        ) );
+
+        if ( ! $cluster ) {
+            return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 );
+        }
+
+        $engine   = $this->plugin->get( 'cluster_engine' );
+        $post_ids = array_map( 'intval', $engine->get_member_post_ids( $cluster ) );
+
+        if ( empty( $post_ids ) ) {
+            return rest_ensure_response( [
+                'unscanned' => 0,
+                'cluster'   => $this->cluster_summary( $cluster ),
+                'members'   => [],
+                'stats'     => [ 'pillar' => 0, 'support' => 0, 'light' => 0, 'orphan' => 0 ],
+            ] );
+        }
+
+        // Fetch metrics for every member in one query.
+        $metrics_rows = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT post_id, word_count, inbound_links, outbound_links,
+                        overall_score, seo_score, structure_score, content_class
+                 FROM {$wpdb->prefix}clusterlane_post_metrics
+                 WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                $post_ids
+            )
+        );
+
+        // Index by post_id (already int from intval above).
+        $metrics = [];
+        foreach ( $metrics_rows as $r ) {
+            $metrics[ (int) $r->post_id ] = $r;
+        }
+
+        // Fetch cluster-relative content class from cpc.
+        $cpc_rows = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT post_id, content_class, inbound_links, percentile
+                 FROM {$wpdb->prefix}clusterlane_cluster_post_class
+                 WHERE cluster_key = %s AND post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                array_merge( [ $key ], $post_ids )
+            )
+        );
+        $cpc = [];
+        foreach ( $cpc_rows as $r ) {
+            $cpc[ (int) $r->post_id ] = $r;
+        }
+
+        // Build members + stats.
+        $stats    = [ 'pillar' => 0, 'support' => 0, 'light' => 0, 'orphan' => 0 ];
+        $members  = [];
+        $unscanned = 0;
+
+        // One query for all posts (and their terms, for permalinks) instead of
+        // one per member — 870 members was ~880 queries before 0.98.119.
+        _prime_post_caches( $post_ids, true, false );
+
+        foreach ( $post_ids as $pid ) {
+            $post = get_post( $pid );
+            if ( ! $post ) continue;
+
+            $m   = $metrics[ $pid ] ?? null;
+            $c   = $cpc[ $pid ]     ?? null;
+            $cls = $c->content_class ?? ( $m->content_class ?? 'light' );
+
+            if ( isset( $stats[ $cls ] ) ) $stats[ $cls ]++;
+            if ( ! $m ) $unscanned++;
+
+            $members[] = [
+                'id'            => $pid,
+                'title'         => $post->post_title,
+                'url'           => get_permalink( $pid ),
+                'edit_url'      => get_edit_post_link( $pid, 'raw' ) ?? admin_url( "post.php?post=$pid&action=edit" ),
+                'content_class' => $cls,
+                'inbound_links' => $m ? (int) $m->inbound_links  : 0,
+                'outbound_links'=> $m ? (int) $m->outbound_links : 0,
+                'overall_score' => $m ? (int) $m->overall_score  : 0,
+                'word_count'    => $m ? (int) $m->word_count     : 0,
+                'percentile'    => $c ? $c->percentile            : null,
+            ];
+        }
+
+        usort( $members, fn( $a, $b ) => $b['inbound_links'] <=> $a['inbound_links'] );
+
+        // Cached AI analysis (Pro) — attached by RestClusterAi via this filter so
+        // the JS can auto-render it; null in the free edition.
+        $cached_ai = apply_filters( 'clusterlane_cluster_detail_ai', null, $cluster, $post_ids, $key );
+
+        return rest_ensure_response( [
+            'unscanned' => $unscanned,
+            'cluster'   => $this->cluster_summary( $cluster ),
+            'members'   => \Clusterlane\Core\Thumbs::add( $members ),
+            'stats'     => $stats,
+            'chosen_pillar' => \Clusterlane\Core\ClusterEngine::chosen_pillar( $key ),
+            'ai'        => $cached_ai,
+        ] );
+    }
+
+    /**
+     * Quick view (0.98.200): the figures that tell in a glance how a cluster is
+     * doing and where to start — without the members table, the graph or AI.
+     * Read-only and cheap: a handful of aggregate queries over the members.
+     */
+    public function quick( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $key     = sanitize_key( $request['key'] );
+        $cluster = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s AND is_active = 1", $key
+        ) );
+        if ( ! $cluster ) return new \WP_REST_Response( [ 'error' => __( 'Cluster not found.', 'clusterlane' ) ], 404 );
+
+        $engine = $this->plugin->get( 'cluster_engine' );
+        $ids    = array_values( array_unique( array_map( 'intval', $engine ? $engine->get_member_post_ids( $cluster ) : [] ) ) );
+        $types  = \Clusterlane\Core\ClusterTypes::for_js();
+        $type   = (string) $cluster->cluster_type;
+        $ai     = admin_url( 'admin.php?page=clusterlane-article-inspector' );
+        $label  = html_entity_decode( (string) $cluster->cluster_label, ENT_QUOTES, 'UTF-8' );
+        $art    = fn( int $id ) => add_query_arg( [ 'post_id' => $id, 'from_cluster' => $key, 'from_cluster_label' => rawurlencode( $label ) ], $ai );
+
+        $roles = [ 'pillar' => 0, 'support' => 0, 'light' => 0, 'orphan' => 0 ];
+        $out   = [
+            'cluster'   => $this->cluster_summary( $cluster ) + [
+                'label_plain' => $label,
+                'type_label'  => $types[ $type ]['label'] ?? $type,
+                'type_glyph'  => $types[ $type ]['glyph'] ?? 'folder',
+            ],
+            'roles'     => $roles,
+            'scanned'   => 0,
+            'avg_score' => null,
+            'links'     => [ 'inside' => 0, 'from_outside' => 0, 'to_outside' => 0, 'hub' => null ],
+            'meta'      => [ 'missing' => 0, 'weak' => 0 ],
+            'pillars'   => [],
+            'attention' => [],
+            'history'   => self::health_series( $key, (int) $cluster->health_score, 84 ),
+            'bands'     => self::bands( $this->plugin->get( 'settings' ) ),
+            'urls'      => [
+                'inspector' => add_query_arg( 'open', $key, admin_url( 'admin.php?page=clusterlane-cluster-inspector' ) ),
+                'seo'       => add_query_arg( 'cluster', $key, admin_url( 'admin.php?page=clusterlane-seo-titles' ) ),
+            ],
+        ];
+        if ( ! $ids ) { $out['focus'] = null; return rest_ensure_response( $out ); }
+
+        $rows = (array) $wpdb->get_results( $wpdb->prepare(
+            "SELECT p.ID AS id, p.post_title AS title, m.post_id AS scanned, m.overall_score AS score, m.inbound_links AS inbound,
+                    COALESCE( c.content_class, m.content_class, 'light' ) AS cls
+             FROM `{$wpdb->posts}` p
+             LEFT JOIN `{$wpdb->prefix}clusterlane_post_metrics` m ON m.post_id = p.ID
+             LEFT JOIN `{$wpdb->prefix}clusterlane_cluster_post_class` c ON c.post_id = p.ID AND c.cluster_key = %s
+             WHERE p.ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")",
+            array_merge( [ $key ], $ids )
+        ) );
+
+        $scores = [];
+        foreach ( $rows as $r ) {
+            if ( isset( $roles[ $r->cls ] ) ) $roles[ $r->cls ]++;
+            if ( $r->scanned !== null ) $scores[] = (int) $r->score;
+        }
+        $out['roles']     = $roles;
+        $out['scanned']   = count( $scores );
+        $out['avg_score'] = $scores ? (int) round( array_sum( $scores ) / count( $scores ) ) : null;
+
+        $item = fn( $r, string $why = '' ) => [
+            'id'      => (int) $r->id,
+            'title'   => html_entity_decode( (string) $r->title, ENT_QUOTES, 'UTF-8' ),
+            'role'    => (string) $r->cls,
+            'score'   => $r->scanned !== null ? (int) $r->score : null,
+            'inbound' => (int) $r->inbound,
+            'why'     => $why,
+            'url'     => $art( (int) $r->id ),
+        ];
+
+        // Pillar: the one chosen in Cluster Inspector, else the strongest pillar(s).
+        $chosen  = \Clusterlane\Core\ClusterEngine::chosen_pillar( $key );
+        $pillars = array_values( array_filter( $rows, fn( $r ) => (int) $r->id === $chosen || $r->cls === 'pillar' ) );
+        usort( $pillars, fn( $a, $b ) => ( ( (int) $b->id === $chosen ) <=> ( (int) $a->id === $chosen ) ) ?: ( (int) $b->inbound <=> (int) $a->inbound ) );
+        $out['pillars'] = array_map( fn( $r ) => $item( $r ) + [ 'chosen' => (int) $r->id === $chosen ], array_slice( $pillars, 0, 2 ) );
+
+        // Where to start: articles nothing in the cluster links to, then the weakest scores.
+        $orph = array_values( array_filter( $rows, fn( $r ) => $r->cls === 'orphan' ) );
+        usort( $orph, fn( $a, $b ) => (int) $b->score <=> (int) $a->score );
+        $weak = array_values( array_filter( $rows, fn( $r ) => $r->cls !== 'orphan' && $r->scanned !== null && (int) $r->score < 60 ) );
+        usort( $weak, fn( $a, $b ) => (int) $a->score <=> (int) $b->score );
+        $att = array_map( fn( $r ) => $item( $r, __( 'No links pointing to it', 'clusterlane' ) ), $orph );
+        foreach ( $weak as $r ) $att[] = $item( $r, __( 'Low article score', 'clusterlane' ) );
+        $out['attention']       = \Clusterlane\Core\Thumbs::add( array_slice( $att, 0, 5 ) );
+        $out['attention_total'] = count( $att );
+        $out['pillars']         = \Clusterlane\Core\Thumbs::add( $out['pillars'] );
+
+        // Links: inside the cluster (same count as the graph's "Links"), into it from the rest of the site, out of it.
+        $both = array_merge( $ids, $ids );
+        $out['links']['inside']       = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'post' AND source_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $both ) );
+        $out['links']['from_outside'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'post' AND target_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND source_id NOT IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $both ) );
+        $out['links']['to_outside']   = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'post' AND source_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") AND target_id NOT IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $both ) );
+
+        // The category/tag archive page: how many members link back to it (as in the graph's hub node).
+        if ( $type !== 'custom' && (int) $cluster->term_id ) {
+            $term = get_term( (int) $cluster->term_id, $type );
+            if ( $term && ! is_wp_error( $term ) ) {
+                $link = get_term_link( $term );
+                $out['links']['hub'] = [
+                    'linked' => (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT source_id) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'term' AND target_id = %d AND source_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", array_merge( [ (int) $cluster->term_id ], $ids ) ) ),
+                    'of'     => count( $ids ),
+                    'url'    => is_wp_error( $link ) ? '' : (string) $link,
+                ];
+            }
+        }
+
+        // Meta descriptions (the same rules as SEO Titles and the Report).
+        $seo  = \Clusterlane\Core\SeoMeta::sql_description( 'p', 'p.ID' );
+        $e    = $seo['expr'];
+        // SeoMeta's expression and join hold no raw values (the meta key is prepared there).
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $meta = $wpdb->get_row( $wpdb->prepare(
+            'SELECT SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'missing' ) . ') AS missing, SUM(' . \Clusterlane\Core\SeoMeta::sql_status( $e, 'weak' ) . ") AS weak FROM {$wpdb->posts} p " . $seo['join'] . " WHERE p.ID IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")",
+            $ids
+        ) );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        $out['meta'] = [ 'missing' => (int) ( $meta->missing ?? 0 ), 'weak' => (int) ( $meta->weak ?? 0 ) ];
+
+        // The biggest lever — the same reasons, in the same order, as Focus next.
+        $h = (int) $cluster->health_score;
+        $n = count( $ids );
+        $o = $roles['orphan'];
+        /* translators: %s: number of articles */
+        if ( $o > 0 )                                       $focus = sprintf( _n( '%s article has no links pointing to it.', '%s articles have no links pointing to them.', $o, 'clusterlane' ), number_format_i18n( $o ) );
+        elseif ( $roles['pillar'] === 0 && ! $chosen && $n >= 4 ) $focus = __( 'No pillar article anchors the topic yet.', 'clusterlane' );
+        elseif ( $h < 70 && $out['links']['from_outside'] === 0 ) $focus = __( 'No links from the rest of the site point into this cluster.', 'clusterlane' );
+        elseif ( $h < 70 && $n >= 2 )                       $focus = __( 'Its articles link to each other too little.', 'clusterlane' );
+        else                                                $focus = null;
+        $out['focus'] = $focus;
+
+        return rest_ensure_response( $out );
+    }
+
+    /** Health bands from Settings → Cluster thresholds — as the Report and the Dashboard ring. */
+    private static function bands( $settings ): array {
+        $th = $settings ? (array) $settings->get( 'thresholds', [] ) : [];
+        return [ 'green' => (int) ( $th['health_green'] ?? 70 ), 'orange' => (int) ( $th['health_orange'] ?? 50 ) ];
+    }
+
+    /**
+     * Health of one cluster over the last $days days from the daily snapshots —
+     * at most 13 points (about one a week), plus today's live figure last.
+     * @return array<int,array{d:string,h:int}>
+     */
+    private static function health_series( string $key, int $live, int $days ): array {
+        global $wpdb;
+        $today = current_time( 'Y-m-d' );
+        $dates = (array) $wpdb->get_col( $wpdb->prepare(
+            "SELECT snap_date FROM {$wpdb->prefix}clusterlane_snapshots WHERE snap_date >= %s AND snap_date < %s ORDER BY snap_date ASC",
+            wp_date( 'Y-m-d', time() - $days * DAY_IN_SECONDS ), $today
+        ) );
+        $pick = [];
+        $n    = count( $dates );
+        if ( $n ) {
+            $step = max( 1, (int) ceil( $n / 12 ) );
+            for ( $i = $n - 1; $i >= 0; $i -= $step ) $pick[] = $dates[ $i ]; // newest backwards, so the last one is always in
+            $pick = array_reverse( $pick );
+        }
+        $out = [];
+        if ( $pick ) {
+            $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT snap_date, data FROM {$wpdb->prefix}clusterlane_snapshots WHERE snap_date IN (" . implode( ',', array_fill( 0, count( $pick ), '%s' ) ) . ") ORDER BY snap_date ASC", $pick ) );
+            foreach ( $rows as $r ) {
+                $d = json_decode( (string) $r->data, true );
+                foreach ( (array) ( $d['clusters'] ?? [] ) as $c ) {
+                    if ( ( $c['k'] ?? '' ) === $key && (int) ( $c['n'] ?? 0 ) > 0 ) { $out[] = [ 'd' => (string) $r->snap_date, 'h' => (int) $c['h'] ]; break; }
+                }
+            }
+        }
+        $out[] = [ 'd' => $today, 'h' => $live ];
+        return $out;
+    }
+
+    private function cluster_summary( object $c ): array {
+        return [
+            'key'       => $c->cluster_key,
+            'label'     => $c->cluster_label,
+            'type'      => $c->cluster_type,
+            'archetype' => $c->archetype  ?: 'Unclassified',
+            'health'    => (int) $c->health_score,
+            'entropy'   => (int) $c->entropy_score,
+            'gini'      => (int) $c->gini_score,
+            'density'   => (int) $c->density_score,
+            'members'   => (int) $c->total_members,
+        ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-clusters.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-clusters.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-clusters.php	(revision 3751027)
@@ -0,0 +1,462 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+class RestClusters {
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+        // List all clusters.
+        register_rest_route( 'clusterlane/v1', '/clusters', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'get_clusters' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Sync clusters from taxonomy.
+        register_rest_route( 'clusterlane/v1', '/clusters/sync', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'sync_clusters' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Compute metrics for all clusters.
+        register_rest_route( 'clusterlane/v1', '/clusters/compute', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'compute_clusters' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Compute metrics for one cluster.
+        register_rest_route( 'clusterlane/v1', '/clusters/compute/(?P<key>[a-z0-9_\-]+)', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'compute_one' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // --- Custom clusters ---
+
+        // Create a new custom cluster.
+        register_rest_route( 'clusterlane/v1', '/clusters/custom', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'create_custom' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+        // Update custom cluster (rename / add / remove members).
+        register_rest_route( 'clusterlane/v1', '/clusters/custom/(?P<key>[a-z0-9_\-]+)', [
+            'methods'             => 'POST,DELETE',
+            'callback'            => [ $this, 'update_custom' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+        // Search posts for the member picker.
+        register_rest_route( 'clusterlane/v1', '/posts/search', [
+            'methods'             => 'GET,POST',
+            'callback'            => [ $this, 'search_posts' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Get members of a cluster.
+        register_rest_route( 'clusterlane/v1', '/clusters/members/(?P<key>[a-z0-9_\\-]+)', [
+            'methods'             => 'GET,POST',
+            'callback'            => [ $this, 'get_members' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Deactivate a taxonomy cluster.
+        register_rest_route( 'clusterlane/v1', '/clusters/deactivate/(?P<key>[a-z0-9_\\-]+)', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'deactivate_cluster' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+        // Reset — delete all Clusterlane data.
+        register_rest_route( 'clusterlane/v1', '/onboarding/setup', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'onboarding_setup' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/reset', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'reset_all' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+
+    }
+
+    public function get_clusters(): \WP_REST_Response {
+        $engine   = $this->plugin->get('cluster_engine');
+        $clusters = $engine->get_clusters();
+        $summary  = $engine->get_summary();
+
+        // Role counts per cluster (Site map on the Dashboard colours by orphan share).
+        global $wpdb;
+        $role_counts = [];
+        foreach ( (array) $wpdb->get_results(
+            "SELECT cluster_key, content_class, COUNT(*) AS n FROM {$wpdb->prefix}clusterlane_cluster_post_class GROUP BY cluster_key, content_class" // phpcs:ignore
+        ) as $rc ) {
+            $role_counts[ $rc->cluster_key ][ $rc->content_class ] = (int) $rc->n;
+        }
+        foreach ( $clusters as $c ) {
+            $c->orphans = (int) ( $role_counts[ $c->cluster_key ]['orphan'] ?? 0 );
+            $c->pillars = (int) ( $role_counts[ $c->cluster_key ]['pillar'] ?? 0 );
+        }
+        return rest_ensure_response( [
+            'summary'  => $summary,
+            'clusters' => $clusters,
+        ] );
+    }
+
+    public function sync_clusters(): \WP_REST_Response {
+        $created = $this->plugin->get('cluster_engine')->sync_all();
+        return rest_ensure_response( [ 'created' => $created ] );
+    }
+
+    public function compute_clusters(): \WP_REST_Response {
+        $count = $this->plugin->get('cluster_engine')->compute_all();
+        return rest_ensure_response( [ 'computed' => $count ] );
+    }
+
+    public function compute_one( \WP_REST_Request $request ): \WP_REST_Response {
+        $key = sanitize_key( $request['key'] );
+        $this->plugin->get('cluster_engine')->compute_cluster( $key );
+        return rest_ensure_response( [ 'computed' => $key ] );
+    }
+
+    /* ------------------------------------------------------------------
+       Custom clusters
+    ------------------------------------------------------------------ */
+
+    public function create_custom( \WP_REST_Request $request ): \WP_REST_Response {
+        $label   = sanitize_text_field( $request->get_json_params()['label'] ?? '' );
+        $post_ids = array_map( 'intval', $request->get_json_params()['post_ids'] ?? [] );
+
+        if ( ! $label ) {
+            return new \WP_REST_Response( [ 'error' => 'Label is required' ], 400 );
+        }
+
+        global $wpdb;
+
+        // Generate unique key from label.
+        $key  = 'custom_' . sanitize_title( $label ) . '_' . time();
+
+        // Create cluster row.
+        $wpdb->insert( $wpdb->prefix . 'clusterlane_clusters', [
+            'cluster_key'   => $key,
+            'cluster_label' => $label,
+            'cluster_type'  => 'custom',
+            'term_id'       => null,
+            'is_active'     => 1,
+        ] );
+
+        // Add members.
+        $now = current_time( 'mysql' );
+        foreach ( $post_ids as $post_id ) {
+            if ( ! $post_id ) continue;
+            $wpdb->replace( $wpdb->prefix . 'clusterlane_custom_members', [
+                'cluster_key' => $key,
+                'post_id'     => $post_id,
+                'added_at'    => $now,
+            ] );
+        }
+
+        // Compute metrics immediately.
+        $this->plugin->get('cluster_engine')->compute_cluster( $key );
+
+        return rest_ensure_response( [
+            'cluster_key' => $key,
+            'label'       => $label,
+            'members'     => count( $post_ids ),
+            'summary'     => $this->custom_summary( $key ),
+        ] );
+    }
+
+    public function update_custom( \WP_REST_Request $request ): \WP_REST_Response {
+        $key    = sanitize_key( $request['key'] );
+        $method = $request->get_method();
+
+        global $wpdb;
+
+        // DELETE — remove entire custom cluster.
+        if ( $method === 'DELETE' ) {
+            $wpdb->delete( $wpdb->prefix . 'clusterlane_custom_members', [ 'cluster_key' => $key ] );
+            $wpdb->delete( $wpdb->prefix . 'clusterlane_clusters',        [ 'cluster_key' => $key ] );
+            return rest_ensure_response( [ 'deleted' => $key ] );
+        }
+
+        // POST — update label and/or members.
+        $params   = $request->get_json_params();
+        $label    = sanitize_text_field( $params['label']    ?? '' );
+        $add_ids  = array_map( 'intval', $params['add_ids']  ?? [] );
+        $remove_ids = array_map( 'intval', $params['remove_ids'] ?? [] );
+
+        if ( $label ) {
+            $wpdb->update(
+                $wpdb->prefix . 'clusterlane_clusters',
+                [ 'cluster_label' => $label ],
+                [ 'cluster_key'   => $key ]
+            );
+        }
+
+        $now = current_time( 'mysql' );
+        foreach ( $add_ids as $post_id ) {
+            if ( ! $post_id ) continue;
+            $wpdb->replace( $wpdb->prefix . 'clusterlane_custom_members', [
+                'cluster_key' => $key,
+                'post_id'     => $post_id,
+                'added_at'    => $now,
+            ] );
+        }
+
+        foreach ( $remove_ids as $post_id ) {
+            if ( ! $post_id ) continue;
+            $wpdb->delete( $wpdb->prefix . 'clusterlane_custom_members', [
+                'cluster_key' => $key,
+                'post_id'     => $post_id,
+            ] );
+        }
+
+        // Recompute metrics.
+        $this->plugin->get('cluster_engine')->compute_cluster( $key );
+
+        return rest_ensure_response( [ 'updated' => $key, 'summary' => $this->custom_summary( $key ) ] );
+    }
+
+    /** Health, size and orphans of one cluster — for Cluster Admin's table after an edit. */
+    private function custom_summary( string $key ): array {
+        global $wpdb;
+        $c = $wpdb->get_row( $wpdb->prepare( "SELECT cluster_label, health_score, total_members FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) );
+        $orphans = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_cluster_post_class WHERE cluster_key = %s AND content_class = 'orphan'", $key ) );
+        return [
+            'label'   => $c ? html_entity_decode( $c->cluster_label, ENT_QUOTES, 'UTF-8' ) : '',
+            'health'  => $c ? (int) $c->health_score : 0,
+            'members' => $c ? (int) $c->total_members : 0,
+            'orphans' => $orphans,
+        ];
+    }
+
+    public function search_posts( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+
+        $query = sanitize_text_field( $request->get_param('q') ?? '' );
+        $limit = min( 20, (int) ( $request->get_param('limit') ?? 10 ) );
+
+        if ( strlen( $query ) < 2 ) {
+            return rest_ensure_response( [ 'posts' => [] ] );
+        }
+
+        $settings   = $this->plugin->get('settings');
+        $post_types = $settings ? $settings->post_types() : ['post'];
+        $like = '%' . $wpdb->esc_like( $query ) . '%';
+        // same_as=<post ID> (0.98.168): multilingual sites — only articles in that article's language.
+        $lang_sql = \Clusterlane\Core\Lang::sql( \Clusterlane\Core\Lang::of( (int) $request->get_param( 'same_as' ) ), '' ); // prepared in Lang::sql() (or 1=1)
+
+        // 1. Title matches — always shown first, sorted alphabetically.
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $title_rows = $wpdb->get_results( $wpdb->prepare(
+            "SELECT ID, post_title FROM {$wpdb->posts}
+             WHERE post_title LIKE %s AND " . $lang_sql . "
+               AND post_status = %s
+               AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ")
+             ORDER BY post_title ASC
+             LIMIT %d",
+            array_merge( [ $like, 'publish' ], $post_types, [ $limit ] )
+        ) );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+
+        $found_ids = array_column( $title_rows, 'ID' );
+
+        // 2. Content matches — fill remaining slots, exclude already-found posts.
+        $content_rows = [];
+        $remaining    = $limit - count( $title_rows );
+        if ( $remaining > 0 ) {
+            $exclude      = $found_ids ? array_values( array_map( 'intval', $found_ids ) ) : [ 0 ]; // no post has ID 0
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $content_rows = $wpdb->get_results( $wpdb->prepare(
+                "SELECT ID, post_title FROM {$wpdb->posts}
+                 WHERE post_content LIKE %s AND " . $lang_sql . "
+                   AND post_status = %s
+                   AND post_type IN (" . implode( ',', array_fill( 0, count( $post_types ), '%s' ) ) . ")
+                   AND ID NOT IN (" . implode( ',', array_fill( 0, count( $exclude ), '%d' ) ) . ")
+                 ORDER BY post_title ASC
+                 LIMIT %d",
+                array_merge( [ $like, 'publish' ], $post_types, $exclude, [ $remaining ] )
+            ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        }
+
+        $title_results   = array_map( fn( $p ) => [
+            'id'         => (int) $p->ID,
+            'title'      => $p->post_title,
+            'url'        => get_permalink( $p->ID ),
+            'match_type' => 'title',
+        ], $title_rows );
+
+        $content_results = array_map( fn( $p ) => [
+            'id'         => (int) $p->ID,
+            'title'      => $p->post_title,
+            'url'        => get_permalink( $p->ID ),
+            'match_type' => 'content',
+        ], $content_rows );
+
+        $posts = array_merge( $title_results, $content_results );
+        if ( \Clusterlane\Core\Lang::active() ) { // a language tag per result, so same titles in two languages can be told apart
+            $langs = \Clusterlane\Core\Lang::map( array_column( $posts, 'id' ) );
+            foreach ( $posts as &$p ) $p['lang'] = $langs[ $p['id'] ] ?? '';
+            unset( $p );
+        }
+        return rest_ensure_response( [ 'posts' => \Clusterlane\Core\Thumbs::add( $posts ) ] );
+    }
+
+    public function get_members( \WP_REST_Request $request ): \WP_REST_Response {
+        $key     = sanitize_key( $request['key'] );
+        $cluster = null;
+
+        global $wpdb;
+        $cluster = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s",
+            $key
+        ) );
+
+        if ( ! $cluster ) {
+            return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 );
+        }
+
+        // Get member post IDs.
+        if ( \Clusterlane\Core\HubClusters::is_hub_type( (string) $cluster->cluster_type ) ) {
+            $ids = \Clusterlane\Core\HubClusters::members( (int) $cluster->term_id ); // pages as clusters (0.98.180)
+        } elseif ( $cluster->cluster_type === 'custom' ) {
+            $ids = $wpdb->get_col( $wpdb->prepare(
+                "SELECT post_id FROM {$wpdb->prefix}clusterlane_custom_members WHERE cluster_key = %s ORDER BY added_at DESC",
+                $key
+            ) );
+        } else {
+            $ids = get_posts( \Clusterlane\Core\Scope::query_args( [
+                'post_type'      => \Clusterlane\Core\Scope::post_types(),
+                'post_status'    => 'publish',
+                'posts_per_page' => -1,
+                'fields'         => 'ids',
+                'tax_query'      => [[ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- members of one term, ids only
+                    'taxonomy'         => $cluster->cluster_type,
+                    'field'            => 'term_id',
+                    'terms'            => (int) $cluster->term_id,
+                    'include_children' => false,
+                ]],
+                'no_found_rows'  => true,
+            ] ) );
+        }
+
+        $members = array_map( function( $id ) {
+            $post = get_post( (int) $id );
+            return $post ? [ 'id' => $post->ID, 'title' => $post->post_title ] : null;
+        }, $ids );
+
+        $members = array_filter( $members );
+        usort( $members, fn( $a, $b ) => strcmp( $a['title'], $b['title'] ) );
+
+        return rest_ensure_response( [ 'members' => $members ] );
+    }
+
+    public function deactivate_cluster( \WP_REST_Request $request ): \WP_REST_Response {
+        $key = sanitize_key( $request['key'] );
+
+        global $wpdb;
+        $wpdb->update(
+            $wpdb->prefix . 'clusterlane_clusters',
+            [ 'is_active' => 0 ],
+            [ 'cluster_key' => $key ]
+        );
+
+        return rest_ensure_response( [ 'deactivated' => $key ] );
+    }
+
+    public function onboarding_setup( \WP_REST_Request $request ): \WP_REST_Response {
+        $settings = $this->plugin->get( 'settings' );
+        $raw_cats = array_map( 'intval', (array) ( $request->get_json_params()['categories'] ?? [] ) );
+        $raw_tags = array_map( 'intval', (array) ( $request->get_json_params()['tags']       ?? [] ) );
+
+        // Validate that every submitted ID is a real term on this site.
+        // Prevents arbitrary integers ending up in settings if the request
+        // is crafted or the JS sends stale data after a term was deleted.
+        $cats = array_values( array_filter( $raw_cats, fn( $id ) => $id > 0 && term_exists( $id, 'category' ) ) );
+        $tags = array_values( array_filter( $raw_tags, fn( $id ) => $id > 0 && term_exists( $id, 'post_tag' ) ) );
+
+        $settings->set( 'enabled_categories', array_unique( $cats ) );
+        $settings->set( 'enabled_tags',       array_unique( $tags ) );
+
+        // 0.98.177: what to scan, whose published pages to read, and terms of any
+        // other taxonomy (attraction categories, regions …) as clusters.
+        $p       = (array) $request->get_json_params();
+        $public  = array_diff( array_keys( get_post_types( [ 'public' => true ] ) + get_post_types( [ 'publicly_queryable' => true ] ) ), [ 'attachment' ] );
+        $types   = array_values( array_intersect( array_map( 'sanitize_key', (array) ( $p['post_types'] ?? [] ) ), $public ) );
+        if ( $types ) $settings->set( 'post_types', $types );
+        if ( isset( $p['rendered_types'] ) ) {
+            $settings->set( 'rendered_types', array_values( array_intersect( array_map( 'sanitize_key', (array) $p['rendered_types'] ), $settings->post_types() ) ) );
+        }
+        $extra = [];
+        foreach ( (array) ( $p['terms'] ?? [] ) as $tax => $ids ) {
+            $tax = sanitize_key( (string) $tax );
+            if ( ! taxonomy_exists( $tax ) || in_array( $tax, \Clusterlane\Core\ClusterTypes::BUILT_IN, true ) ) continue;
+            $extra[ $tax ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ), fn( $id ) => $id > 0 && term_exists( $id, $tax ) ) ) );
+        }
+        $settings->set( 'enabled_terms', $extra );
+        // Pages as clusters (0.98.180), among what the saved post types point to.
+        $settings->set( 'enabled_hubs', array_values( array_intersect( array_map( 'sanitize_key', (array) ( $p['hubs'] ?? [] ) ), array_keys( \Clusterlane\Core\HubClusters::candidates() ) ) ) );
+        $settings->set( 'cluster_terms_configured', true );
+
+        // Sync clusters from the saved selection.
+        $engine  = $this->plugin->get( 'cluster_engine' );
+        $created = $engine ? $engine->sync_all() : 0;
+
+        return rest_ensure_response( [ 'ok' => true, 'clusters_created' => $created ] );
+    }
+
+    public function reset_all(): \WP_REST_Response {
+        global $wpdb;
+        $tables = [
+            $wpdb->prefix . 'clusterlane_links',
+            $wpdb->prefix . 'clusterlane_clusters',
+            $wpdb->prefix . 'clusterlane_post_metrics',
+            $wpdb->prefix . 'clusterlane_ai_cache',
+            $wpdb->prefix . 'clusterlane_custom_members',
+            $wpdb->prefix . 'clusterlane_tms_skipped',
+            $wpdb->prefix . 'clusterlane_cluster_post_class',
+        ];
+        foreach ( $tables as $table ) {
+            $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', $table ) );
+        }
+        // AI reviews of SEO titles (post meta) go with the AI cache.
+        delete_post_meta_by_key( \Clusterlane\Core\SeoMeta::CHECK_META );
+        delete_post_meta_by_key( \Clusterlane\Core\SeoMeta::CHECK_MIN );
+        delete_option( \Clusterlane\Core\ClusterSections::OPTION ); delete_option( \Clusterlane\Core\ClusterSections::GROUPS ); // clusters are gone, so are their sections
+
+        // Clear data-related settings (link templates) but keep API key and other config
+        // so the user doesn't have to re-enter credentials after a reset.
+        // Reset cluster_terms_configured so the onboarding modal reappears.
+        $settings = get_option( 'clusterlane_settings', [] );
+        unset( $settings['link_templates'] );
+        $settings['cluster_terms_configured'] = false;
+        $settings['enabled_categories']       = [];
+        $settings['enabled_tags']             = [];
+        $settings['enabled_product_cats']     = [];
+        $settings['enabled_terms']            = [];
+        $settings['enabled_hubs']             = [];
+        update_option( 'clusterlane_settings', $settings );
+        \Clusterlane\Core\PageReader::reset_log();
+        \Clusterlane\Core\PageReader::reset_queue();
+        delete_post_meta_by_key( \Clusterlane\Core\PageReader::STATS_META );
+        delete_option( \Clusterlane\Core\PageReader::LOCK_OPTION );
+        wp_clear_scheduled_hook( \Clusterlane\Core\PageReader::CRON_HOOK );
+
+        return rest_ensure_response( [ 'reset' => true ] );
+    }
+
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-graph.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-graph.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-graph.php	(revision 3751027)
@@ -0,0 +1,274 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * RestGraph
+ *
+ * Returns node + edge data for a cluster's vis.js graph.
+ */
+class RestGraph {
+
+    /** Most article nodes one graph draws (0.98.169). */
+    public const MAX_NODES = 150;
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+        register_rest_route( 'clusterlane/v1', '/graph/(?P<key>[a-z0-9_\-]+)', [
+            'methods'             => 'GET,POST',
+            'callback'            => [ $this, 'get_graph' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+    }
+
+    public function get_graph( \WP_REST_Request $request ): \WP_REST_Response {
+        $key = sanitize_key( $request['key'] );
+
+        global $wpdb;
+
+        $cluster = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s",
+            $key
+        ) );
+
+        if ( ! $cluster ) {
+            return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 );
+        }
+
+        // Get member post IDs.
+        $post_ids = $this->plugin->get( 'cluster_engine' )->get_member_post_ids( $cluster );
+
+        if ( empty( $post_ids ) ) {
+            return rest_ensure_response( [ 'nodes' => [], 'edges' => [] ] );
+        }
+
+        // Big clusters (0.98.169): a browser cannot lay out thousands of nodes — 2,000
+        // articles froze the page for ~30 s and drew an unreadable ball. Draw the
+        // MAX_NODES that matter most: pillars, the best-linked articles, and a share
+        // of the orphans (what needs fixing). The response says what was left out.
+        $total_members = count( $post_ids );
+        if ( $total_members > self::MAX_NODES ) {
+            $post_ids = array_values( array_map( 'intval', $post_ids ) );
+            $rank = $wpdb->get_results( $wpdb->prepare(
+                "SELECT m.post_id, COALESCE( c.content_class, m.content_class ) AS cls, m.inbound_links AS inb, m.overall_score AS sc
+                 FROM {$wpdb->prefix}clusterlane_post_metrics m
+                 LEFT JOIN {$wpdb->prefix}clusterlane_cluster_post_class c ON c.post_id = m.post_id AND c.cluster_key = %s
+                 WHERE m.post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                array_merge( [ $key ], $post_ids )
+            ) );
+            $orph = array_values( array_filter( $rank, fn( $r ) => $r->cls === 'orphan' ) );
+            $rest = array_values( array_filter( $rank, fn( $r ) => $r->cls !== 'orphan' ) );
+            usort( $rest, fn( $a, $b ) => ( ( $b->cls === 'pillar' ) <=> ( $a->cls === 'pillar' ) ) ?: ( (int) $b->inb <=> (int) $a->inb ) ?: ( (int) $b->sc <=> (int) $a->sc ) );
+            usort( $orph, fn( $a, $b ) => (int) $b->sc <=> (int) $a->sc );
+            // Links between all members, not just the drawn ones — for the "Links" figure.
+            $links_total = (int) $wpdb->get_var( $wpdb->prepare(
+                "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_type = 'post' AND source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                array_merge( $post_ids, $post_ids )
+            ) );
+            $n_orph   = min( count( $orph ), (int) round( self::MAX_NODES * 0.2 ) );
+            $keep     = array_merge( array_slice( $rest, 0, self::MAX_NODES - $n_orph ), array_slice( $orph, 0, $n_orph ) );
+            if ( count( $keep ) < self::MAX_NODES ) $keep = array_merge( $keep, array_slice( $orph, $n_orph, self::MAX_NODES - count( $keep ) ) );
+            $post_ids = array_map( fn( $r ) => (int) $r->post_id, $keep );
+        }
+
+
+        // Get metrics for each member.
+        $metrics = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT post_id, inbound_links, outbound_links, overall_score, content_class
+                 FROM {$wpdb->prefix}clusterlane_post_metrics
+                 WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                $post_ids
+            )
+        );
+
+        $metric_map = [];
+        foreach ( $metrics as $m ) {
+            $metric_map[ (int) $m->post_id ] = $m;
+        }
+
+        // Get this cluster's own cluster-relative classification
+        // (pillar/support/light/orphan is computed against THIS cluster's
+        // inbound-link distribution — see Cluster_Engine::compute_cluster()).
+        // Falls back to the global content_class on clusterlane_post_metrics only
+        // for clusters that haven't had "Compute Metrics" run yet.
+        $class_rows = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT post_id, content_class FROM {$wpdb->prefix}clusterlane_cluster_post_class
+                 WHERE cluster_key = %s AND post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                array_merge( [ $key ], $post_ids )
+            )
+        );
+
+        $class_map = [];
+        foreach ( $class_rows as $c ) {
+            $class_map[ (int) $c->post_id ] = $c->content_class;
+        }
+
+        // Get internal links between cluster members (post-to-post only —
+        // links to the category hub are fetched separately below).
+        $links = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_links
+                 WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")
+                 AND target_type = 'post'",
+                array_merge( $post_ids, $post_ids )
+            )
+        );
+
+        // Build nodes.
+        $nodes = [];
+        foreach ( $post_ids as $post_id ) {
+            $post   = get_post( $post_id );
+            if ( ! $post ) continue;
+
+            $m       = $metric_map[ $post_id ] ?? null;
+            $inbound = $m ? (int) $m->inbound_links  : 0;
+            // Prefer the cluster-relative class; fall back to the global
+            // one (set at scan time) only if "Compute Metrics" hasn't
+            // run for this cluster yet.
+            $class   = $class_map[ $post_id ] ?? ( $m ? $m->content_class : 'light' );
+            $score   = $m ? (int) $m->overall_score   : 0;
+
+            // Node size based on inbound links.
+            $size = max( 10, min( 40, 10 + $inbound * 3 ) );
+
+            // Color based on content class.
+            $color = match( $class ) {
+                'pillar'  => [ 'background' => '#e76c2e', 'border' => '#b84e1e', 'highlight' => [ 'background' => '#f07840', 'border' => '#b84e1e' ] ],
+                'support' => [ 'background' => '#f5c842', 'border' => '#c9a020', 'highlight' => [ 'background' => '#ffd54f', 'border' => '#c9a020' ] ],
+                'light'   => [ 'background' => '#90b865', 'border' => '#5d8a30', 'highlight' => [ 'background' => '#a8cc7a', 'border' => '#5d8a30' ] ],
+                'orphan'  => [ 'background' => '#5ba4cf', 'border' => '#2979a8', 'highlight' => [ 'background' => '#72b7e0', 'border' => '#2979a8' ] ],
+                default   => [ 'background' => '#cccccc', 'border' => '#999999', 'highlight' => [ 'background' => '#dddddd', 'border' => '#999999' ] ],
+            };
+
+            $nodes[] = [
+                'id'       => $post_id,
+                'label'    => mb_strlen( $ptitle = html_entity_decode( (string) $post->post_title, ENT_QUOTES, 'UTF-8' ) ) > 30
+                              ? mb_substr( $ptitle, 0, 28 ) . '…'
+                              : $ptitle,
+                'title'    => $ptitle . "\nInbound: $inbound | Score: $score",
+                'value'    => $size,
+                'size'     => $size,
+                'color'    => $color,
+                'url'      => get_edit_post_link( $post_id, 'raw' ),
+                'class'    => $class,
+                'inbound'  => $inbound,
+            ];
+        }
+
+        // Build edges.
+        $edges = [];
+        foreach ( $links as $i => $link ) {
+            $edges[] = [
+                'id'     => 'e' . $i,
+                'from'   => (int) $link->source_id,
+                'to'     => (int) $link->target_id,
+                'arrows' => 'to',
+                'color'  => [ 'color' => '#cccccc', 'opacity' => 0.6 ],
+            ];
+        }
+
+        // --- Category/tag hub node ---
+        // Taxonomy-backed clusters (not custom ones) have a real archive
+        // page members can link back to. Show it as its own node, with
+        // edges only for posts that ACTUALLY link to it — this is meant
+        // to answer "do our articles link back to the hub?", not just
+        // decorate the graph with cluster membership.
+        if ( $cluster->cluster_type !== 'custom' && $cluster->term_id ) {
+            $term = get_term( (int) $cluster->term_id, $cluster->cluster_type );
+
+            if ( $term && ! is_wp_error( $term ) ) {
+                $hub_id = 'term_' . $cluster->term_id;
+
+                $hub_inbound_rows = $wpdb->get_results(
+                    $wpdb->prepare(
+                        "SELECT source_id FROM {$wpdb->prefix}clusterlane_links
+                         WHERE target_id = %d AND target_type = 'term'
+                         AND source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                        array_merge( [ (int) $cluster->term_id ], $post_ids )
+                    )
+                );
+                $hub_inbound = count( $hub_inbound_rows );
+
+                $hub_size = max( 14, min( 44, 14 + $hub_inbound * 3 ) );
+                $hub_url  = get_term_link( $term );
+                if ( is_wp_error( $hub_url ) ) $hub_url = '';
+
+                // Term names are stored HTML-encoded ("&amp;"); the graph draws plain text.
+                $hub_name = html_entity_decode( (string) $term->name, ENT_QUOTES, 'UTF-8' );
+                $nodes[] = [
+                    'id'      => $hub_id,
+                    'label'   => mb_strlen( $hub_name ) > 30
+                                 ? mb_substr( $hub_name, 0, 28 ) . '…'
+                                 : $hub_name,
+                    'title'   => $hub_name . " (category hub)"
+                                 . "\nLinks to: all " . $total_members . " posts (via archive page)"
+                                 . "\nLinked from: $hub_inbound / " . count( $post_ids ) . ( count( $post_ids ) < $total_members ? ' shown' : '' ) . " posts (in-content)",
+                    'shape'   => 'diamond',
+                    'value'   => $hub_size,
+                    'size'    => $hub_size,
+                    'color'   => [ 'background' => '#6f5bb0', 'border' => '#4a3a80', 'highlight' => [ 'background' => '#8a76c9', 'border' => '#4a3a80' ] ],
+                    'url'     => $hub_url,
+                    'class'   => 'hub',
+                    'inbound' => $hub_inbound,
+                ];
+
+                // The archive page itself lists and links to every member
+                // post — that's what a category/tag archive template does,
+                // independent of anything scanned in post content. Drawn
+                // as a distinct, faint dashed edge so it reads as
+                // "structural" rather than an editorial in-content link.
+                // (Assumes a stock archive template; a heavily customized
+                // one that hides the post list would make this untrue.)
+                foreach ( $post_ids as $k => $member_id ) {
+                    $edges[] = [
+                        'id'     => 'hubout' . $k,
+                        'from'   => $hub_id,
+                        'to'     => $member_id,
+                        'arrows' => 'to',
+                        'dashes' => true,
+                        'color'  => [ 'color' => '#d8d0ee', 'opacity' => 0.35 ],
+                    ];
+                }
+
+                foreach ( $hub_inbound_rows as $j => $row ) {
+                    $edges[] = [
+                        'id'     => 'hub' . $j,
+                        'from'   => (int) $row->source_id,
+                        'to'     => $hub_id,
+                        'arrows' => 'to',
+                        'color'  => [ 'color' => '#b0a4dc', 'opacity' => 0.8 ],
+                    ];
+                }
+            }
+        }
+
+        // Thumbnails for the node tooltips (article nodes only; hubs have string IDs).
+        $nodes = \Clusterlane\Core\Thumbs::add( $nodes );
+
+        return rest_ensure_response( [
+            'nodes'   => $nodes,
+            'edges'   => $edges,
+            // Set when the graph shows only part of a big cluster (0.98.169).
+            'truncated' => count( $post_ids ) < $total_members ? [ 'shown' => count( $post_ids ), 'total' => $total_members, 'links' => $links_total ?? 0 ] : null,
+            'cluster' => [
+                'label'    => $cluster->cluster_label,
+                'type'     => $cluster->cluster_type,
+                'archetype'=> $cluster->archetype,
+                'health'   => $cluster->health_score,
+                'members'  => $total_members,
+                'entropy'  => (int) $cluster->entropy_score,
+                'gini'     => (int) $cluster->gini_score,
+                'density'  => (int) $cluster->density_score,
+            ],
+        ] );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-scanner.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-scanner.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-scanner.php	(revision 3751027)
@@ -0,0 +1,131 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+class RestScanner {
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+        // Get all post IDs to scan.
+        register_rest_route( 'clusterlane/v1', '/scan/posts', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'get_post_ids' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Scan a BATCH of posts in one request (fast).
+        register_rest_route( 'clusterlane/v1', '/scan/batch', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'scan_batch' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Published pages waiting to be read after a scan (0.98.178): where it
+        // stands (GET), or read the next ~20 s of them (POST).
+        register_rest_route( 'clusterlane/v1', '/scan/pages', [
+            [
+                'methods'             => 'GET',
+                'callback'            => fn() => rest_ensure_response( \Clusterlane\Core\PageReader::status() ),
+                'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+            ],
+            [
+                'methods'             => 'POST',
+                'callback'            => fn() => rest_ensure_response( \Clusterlane\Core\PageReader::run_queue() ),
+                'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+            ],
+        ] );
+
+        // Scan a single post (kept for compatibility).
+        register_rest_route( 'clusterlane/v1', '/scan/post/(?P<id>\d+)', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'scan_post' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+            'args'                => [
+                'id' => [
+                    'validate_callback' => fn( $v ) => is_numeric( $v ),
+                    'sanitize_callback' => 'absint',
+                ],
+            ],
+        ] );
+    }
+
+    public function get_post_ids(): \WP_REST_Response {
+        $settings = $this->plugin->get('settings');
+        // A full scan starts here: drop data for posts that left the scope
+        // (unpublished, deleted or password-protected since the last scan).
+        \Clusterlane\Core\Scope::purge_out_of_scope();
+        \Clusterlane\Core\PageReader::reset_log();   // this scan's page-reading report starts empty
+        \Clusterlane\Core\PageReader::reset_queue(); // and so does the queue of pages to read
+        $ids      = $this->plugin->get('scanner')->get_all_post_ids( $settings->post_types() );
+        return rest_ensure_response( [ 'ids' => $ids, 'total' => count( $ids ), 'reads_pages' => \Clusterlane\Core\PageReader::any_enabled() ] );
+    }
+
+    /**
+     * Scan a batch of posts in one request.
+     * Expects JSON body: { "ids": [1, 2, 3, ...] }
+     * Returns: { "scanned": N, "results": [{post_id, links_found}, ...] }
+     */
+    /** Maximum post IDs accepted per batch request. */
+    const MAX_BATCH_SIZE = 100;
+
+    /** Seconds a batch may run before it hands the rest back (0.98.177). */
+    const TIME_BUDGET = 20;
+
+    public function scan_batch( \WP_REST_Request $request ): \WP_REST_Response {
+        $raw = $request->get_json_params()['ids'] ?? [];
+
+        if ( empty( $raw ) || ! is_array( $raw ) ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing ids array' ], 400 );
+        }
+
+        // Cast every element to int and enforce an upper limit to prevent
+        // a single request from scanning thousands of posts and timing out.
+        $ids = array_slice( array_map( 'intval', $raw ), 0, self::MAX_BATCH_SIZE );
+
+        // A batch of 25 posts can exceed a strict host max_execution_time
+        // (often 30s on shared hosting) because link extraction resolves
+        // every URL. Without this guard the request dies mid-batch and
+        // the whole batch is lost, which shows up as "only N of M scanned".
+        if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- one scan batch on an admin's request; harmless where disabled
+        ignore_user_abort( true );
+
+        $scanner = $this->plugin->get('scanner');
+        $results = [];
+        $start   = microtime( true );
+        $left    = [];
+
+        // 'later' (the Scan button, 0.98.178): read the text now, queue the
+        // published pages. 'now' (onboarding, older screens): read them here.
+        $later = ( $request->get_json_params()['pages'] ?? 'now' ) === 'later';
+        \Clusterlane\Core\PageReader::defer( $later );
+
+        foreach ( $ids as $i => $post_id ) {
+            $post_id = (int) $post_id;
+            if ( ! $post_id ) continue;
+
+            // Reading published pages takes a second or so each (0.98.177): stop after
+            // ~20 s and hand the rest back, well inside hosts' and proxies' time limits.
+            if ( $results && microtime( true ) - $start > self::TIME_BUDGET ) {
+                $left = array_values( array_slice( $ids, $i ) );
+                break;
+            }
+            $links_found = $scanner->scan_post( $post_id );
+            $results[]   = [ 'post_id' => $post_id, 'links_found' => $links_found ];
+        }
+        \Clusterlane\Core\PageReader::defer( false );
+
+        return rest_ensure_response( [
+            'scanned'   => count( $results ),
+            'results'   => $results,
+            'remaining' => $left,                          // not scanned yet: the client sends them again
+            'pages'     => \Clusterlane\Core\PageReader::flush_log(), // published pages read / failed in this batch
+            'queued'    => \Clusterlane\Core\PageReader::flush_queue(), // published pages left for later (pages: 'later')
+        ] );
+    }
+
+    public function scan_post( \WP_REST_Request $request ): \WP_REST_Response {
+        $post_id = (int) $request['id'];
+        $count   = $this->plugin->get('scanner')->scan_post( $post_id );
+        return rest_ensure_response( [ 'post_id' => $post_id, 'links_found' => $count ] );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-seo.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-seo.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-seo.php	(revision 3751027)
@@ -0,0 +1,210 @@
+<?php
+namespace Clusterlane\Api;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * RestSeo — writing an article's SEO title and meta description (free).
+ *
+ *   POST clusterlane/v1/seo/apply  { post_id, title?, description? }  → writes the given fields
+ *   POST clusterlane/v1/seo/undo   { post_id }                         → within 30 min, if unchanged
+ *
+ * Used by the search preview in Article Inspector (with AI suggestions in Pro)
+ * and by SEO Titles (0.98.154), where anyone may edit by hand. Moved here from
+ * the premium RestSeoAi in 0.98.154 — same routes, same behaviour; only
+ * seo/suggest (the AI) stays premium.
+ *
+ * Write policy: Clusterlane writes ONLY to custom fields (post meta / ACF),
+ * never to an SEO plugin's storage (Yoast indexables, AIOSEO's own table …).
+ * With an SEO plugin the text is copy-only — see SeoMeta::can_write().
+ * An empty title or description means "leave that field as it is".
+ */
+class RestSeo {
+
+    public const UNDO_TTL = 30 * MINUTE_IN_SECONDS;
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+        $write = fn() => \Clusterlane\Core\Capabilities::rest_seo();
+        register_rest_route( 'clusterlane/v1', '/seo/apply', [ 'methods' => 'POST', 'callback' => [ $this, 'apply' ], 'permission_callback' => $write ] );
+        register_rest_route( 'clusterlane/v1', '/seo/undo',  [ 'methods' => 'POST', 'callback' => [ $this, 'undo' ],  'permission_callback' => $write ] );
+        register_rest_route( 'clusterlane/v1', '/seo/restore', [ 'methods' => 'POST', 'callback' => [ $this, 'restore' ], 'permission_callback' => $write ] );
+    }
+
+    public function apply( \WP_REST_Request $request ): \WP_REST_Response {
+        $p       = (array) $request->get_json_params();
+        $post_id = (int) ( $p['post_id'] ?? 0 );
+        $check   = $this->can_write_post( $post_id );
+        if ( $check ) return $check;
+
+        $src   = \Clusterlane\Core\SeoMeta::source();
+        $title = isset( $p['title'] ) ? self::clean( (string) $p['title'] ) : '';
+        $desc  = isset( $p['description'] ) ? self::clean( (string) $p['description'] ) : '';
+        if ( $title === '' && $desc === '' ) {
+            return new \WP_REST_Response( [ 'error' => __( 'Nothing to apply.', 'clusterlane' ) ], 400 );
+        }
+
+        $undo = [ 'user' => get_current_user_id(), 'time' => time(), 'fields' => [] ];
+        foreach ( [ 'title' => $title, 'desc' => $desc ] as $kind => $value ) {
+            $key = $src[ $kind ];
+            if ( $value === '' || ! $key ) continue;
+            $before = (string) get_post_meta( $post_id, $key, true );
+            if ( $before === $value ) continue; // unchanged — nothing to write or undo
+            $undo['fields'][ $key ] = [
+                'existed' => metadata_exists( 'post', $post_id, $key ),
+                'before'  => $before,
+                'after'   => $value,
+            ];
+            \Clusterlane\Core\SeoMeta::keep_original( $post_id, $key, $undo['fields'][ $key ]['existed'], $before ); // only the first time (0.98.197)
+            $this->write( $post_id, $key, $value );
+        }
+        if ( $undo['fields'] ) {
+            \Clusterlane\Core\SeoMeta::clear_check( $post_id ); // the AI review was of the old text
+            set_transient( 'clusterlane_seo_undo_' . $post_id, $undo, self::UNDO_TTL );
+            \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::META_WRITTEN, 'seo', $post_id, 0, implode( ', ', array_keys( $undo['fields'] ) ) );
+        }
+
+        return rest_ensure_response( $this->after_write( $post_id ) + [
+            'undo_until' => $undo['fields'] ? time() + self::UNDO_TTL : 0,
+            'written'    => array_keys( $undo['fields'] ),
+        ] );
+    }
+
+    public function undo( \WP_REST_Request $request ): \WP_REST_Response {
+        $p       = (array) $request->get_json_params();
+        $post_id = (int) ( $p['post_id'] ?? 0 );
+        $check   = $this->can_write_post( $post_id );
+        if ( $check ) return $check;
+
+        $undo = get_transient( 'clusterlane_seo_undo_' . $post_id );
+        if ( ! is_array( $undo ) || empty( $undo['fields'] ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'Nothing to undo — the 30 minutes have passed.', 'clusterlane' ) ], 409 );
+        }
+        // Refuse if someone changed the field since (the editor, another tool).
+        foreach ( $undo['fields'] as $key => $f ) {
+            if ( (string) get_post_meta( $post_id, $key, true ) !== (string) $f['after'] ) {
+                return new \WP_REST_Response( [ 'error' => __( 'The SEO fields have been changed since — undo would overwrite that change.', 'clusterlane' ) ], 409 );
+            }
+        }
+        foreach ( $undo['fields'] as $key => $f ) {
+            if ( $f['existed'] ) $this->write( $post_id, $key, (string) $f['before'] );
+            else                 $this->remove( $post_id, $key );
+        }
+        delete_transient( 'clusterlane_seo_undo_' . $post_id );
+        \Clusterlane\Core\SeoMeta::clear_check( $post_id );
+        // A restore was never counted as a write, so undoing it uncounts nothing (0.98.197).
+        if ( empty( $undo['restore'] ) ) \Clusterlane\Core\Activity::undo_last( \Clusterlane\Core\Activity::META_WRITTEN, $post_id, 0, 'seo' );
+        return rest_ensure_response( $this->after_write( $post_id ) + [ 'undone' => true ] );
+    }
+
+    /**
+     * POST seo/restore { post_id } (0.98.197): the title and description from before
+     * Clusterlane first changed them. The original is kept (restoring twice is harmless),
+     * and the restore itself can be undone for 30 minutes, like Apply.
+     */
+    public function restore( \WP_REST_Request $request ): \WP_REST_Response {
+        $p       = (array) $request->get_json_params();
+        $post_id = (int) ( $p['post_id'] ?? 0 );
+        $check   = $this->can_write_post( $post_id );
+        if ( $check ) return $check;
+        $o = \Clusterlane\Core\SeoMeta::original( $post_id );
+        if ( ! $o ) {
+            return new \WP_REST_Response( [ 'error' => __( 'Clusterlane has not changed this article’s SEO fields — there is no original to restore.', 'clusterlane' ) ], 404 );
+        }
+        $undo = [ 'user' => get_current_user_id(), 'time' => time(), 'fields' => [], 'restore' => true ];
+        foreach ( $o['fields'] as $key => $f ) {
+            $now    = (string) get_post_meta( $post_id, $key, true );
+            $orig   = (string) ( $f['value'] ?? '' );
+            $exists = metadata_exists( 'post', $post_id, $key );
+            if ( $now === $orig && $exists === (bool) $f['existed'] ) continue;
+            $undo['fields'][ $key ] = [ 'existed' => $exists, 'before' => $now, 'after' => $orig ];
+            if ( $f['existed'] ) $this->write( $post_id, $key, $orig );
+            else                 $this->remove( $post_id, $key );
+        }
+        if ( $undo['fields'] ) {
+            \Clusterlane\Core\SeoMeta::clear_check( $post_id );
+            set_transient( 'clusterlane_seo_undo_' . $post_id, $undo, self::UNDO_TTL );
+        }
+        return rest_ensure_response( $this->after_write( $post_id ) + [
+            'restored'   => array_keys( $undo['fields'] ),
+            'undo_until' => $undo['fields'] ? time() + self::UNDO_TTL : 0,
+        ] );
+    }
+
+    /** Null when the current user may write this post's SEO fields, else the error response. */
+    private function can_write_post( int $post_id ): ?\WP_REST_Response {
+        $post = get_post( $post_id );
+        if ( ! $post || \Clusterlane\Core\Scope::excludes( $post ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'Article not found.', 'clusterlane' ) ], 404 );
+        }
+        if ( ! current_user_can( 'edit_post', $post_id ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'You are not allowed to edit this article.', 'clusterlane' ) ], 403 );
+        }
+        if ( ! \Clusterlane\Core\SeoMeta::can_write() ) {
+            return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\SeoMeta::write_note() ], 409 );
+        }
+        return null;
+    }
+
+    /** One line of plain text: entities decoded, no tags, no surrounding quotes, single spaces, ≤ 300 characters. */
+    public static function clean( string $s ): string {
+        $s = trim( wp_strip_all_tags( html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ) ) );
+        $s = trim( $s, " \t\n\r\"'“”„" );
+        return mb_substr( (string) preg_replace( '/\s+/u', ' ', $s ), 0, 300, 'UTF-8' );
+    }
+
+    /** ACF-aware write: update_field() keeps ACF's field reference (_name → field_x). */
+    private function write( int $post_id, string $key, string $value ): void {
+        if ( function_exists( 'update_field' ) && function_exists( 'get_field_object' ) && get_field_object( $key, $post_id, false, false ) ) {
+            update_field( $key, $value, $post_id );
+        } else {
+            update_post_meta( $post_id, $key, wp_slash( $value ) );
+        }
+    }
+
+    private function remove( int $post_id, string $key ): void {
+        if ( function_exists( 'delete_field' ) && function_exists( 'get_field_object' ) && get_field_object( $key, $post_id, false, false ) ) {
+            delete_field( $key, $post_id );
+        } else {
+            delete_post_meta( $post_id, $key );
+        }
+    }
+
+    /** Clear caches, rescan the post (SEO score) and return fresh preview + metrics. */
+    private function after_write( int $post_id ): array {
+        clean_post_cache( $post_id );
+        $scanner = $this->plugin->get( 'scanner' );
+        if ( $scanner ) $scanner->scan_post( $post_id, true );
+
+        global $wpdb;
+        $m = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $post_id ) );
+        $post = get_post( $post_id );
+        return [
+            'seo'     => \Clusterlane\Core\SeoMeta::get( $post ) + [
+                'original'  => \Clusterlane\Core\SeoMeta::original_state( $post_id ),
+                'title_max' => \Clusterlane\Core\SeoMeta::TITLE_MAX,
+                'desc_min'  => \Clusterlane\Core\SeoMeta::DESC_MIN,
+                'desc_max'  => \Clusterlane\Core\SeoMeta::DESC_MAX,
+            ],
+            'metrics' => $m ? [
+                'word_count'      => (int) $m->word_count,
+                'image_count'     => (int) $m->image_count,
+                'h2_count'        => (int) $m->h2_count,
+                'inbound_links'   => (int) $m->inbound_links,
+                'outbound_links'  => (int) $m->outbound_links,
+                'overall_score'   => (int) $m->overall_score,
+                'seo_score'       => (int) $m->seo_score,
+                'structure_score' => (int) $m->structure_score,
+                'content_class'   => $m->content_class,
+                'last_scanned'    => $m->last_scanned,
+            ] : null,
+        ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/api/class-rest-text-match.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/api/class-rest-text-match.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/api/class-rest-text-match.php	(revision 3751027)
@@ -0,0 +1,433 @@
+<?php
+namespace Clusterlane\Api;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * RestTextMatch
+ *
+ * REST endpoints for the Text Match Scanner.
+ * Mirrors the museumlite-intelligence AJAX endpoints but uses
+ * WordPress REST API instead of admin-ajax.php.
+ */
+class RestTextMatch {
+
+    public function __construct( private \Clusterlane\Core\Plugin $plugin ) {}
+
+    public function register_routes(): void {
+
+        register_rest_route( 'clusterlane/v1', '/tms/posts', [
+            'methods'             => 'GET',
+            'callback'            => [ $this, 'get_post_ids' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/tms/scan', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'scan_post' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Quick wins for a whole cluster, server-side: members × members in
+        // time-boxed chunks (was one request per member from the browser —
+        // 870 requests for an 870-article cluster).
+        register_rest_route( 'clusterlane/v1', '/tms/scan-cluster', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'scan_cluster' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Text Match page: many source articles per request, time-boxed
+        // (was one request per article).
+        register_rest_route( 'clusterlane/v1', '/tms/scan-batch', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'scan_batch' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/tms/apply', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'apply_link' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::rest_links(),
+        ] );
+
+        // Persist a skipped match so it never resurfaces.
+        register_rest_route( 'clusterlane/v1', '/tms/unlink', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'unlink' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::rest_links(),
+        ] );
+
+        register_rest_route( 'clusterlane/v1', '/tms/skip', [
+            'methods'             => 'POST',
+            'callback'            => [ $this, 'skip_match' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_use(),
+        ] );
+
+        // Clear all skipped matches (allow them to resurface).
+        // Clear ALL skipped pairs — requires manage_options since this is a
+        // destructive, site-wide action that affects every editor's workflow,
+        // not just the current user's. A regular editor can only add skips,
+        // not wipe the entire list for everyone.
+        register_rest_route( 'clusterlane/v1', '/tms/skip', [
+            'methods'             => 'DELETE',
+            'callback'            => [ $this, 'clear_skipped' ],
+            'permission_callback' => fn() => \Clusterlane\Core\Capabilities::can_manage(),
+        ] );
+    }
+
+    public function get_post_ids( \WP_REST_Request $request ): \WP_REST_Response {
+        $scan_cat = (int) ( $request->get_param( 'scan_cat' ) ?? 0 );
+        $ids      = $this->plugin->get('text_match')->get_all_post_ids( $scan_cat );
+        global $wpdb;
+        $skipped = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_tms_skipped" ); // phpcs:ignore
+        return rest_ensure_response( [ 'ids' => $ids, 'total' => count( $ids ), 'skipped' => $skipped ] );
+    }
+
+    /**
+     * POST tms/scan-cluster { key, cursor } → matches between the cluster's
+     * own articles, scanned in chunks of about 4 seconds. The client calls
+     * again with next_cursor until it is null.
+     */
+    public function scan_cluster( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $p       = (array) $request->get_json_params();
+        $key     = sanitize_key( (string) ( $p['key'] ?? '' ) );
+        $cursor  = max( 0, (int) ( $p['cursor'] ?? 0 ) );
+        $cluster = $key ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s AND is_active = 1", $key ) ) : null;
+        $engine  = $this->plugin->get( 'cluster_engine' );
+        if ( ! $cluster || ! $engine ) return new \WP_REST_Response( [ 'error' => 'Cluster not found' ], 404 );
+
+        $members = array_map( 'intval', $engine->get_member_post_ids( $cluster ) );
+        sort( $members );
+        $total   = count( $members );
+
+        $tm = $this->plugin->get( 'text_match' );
+        $settings = $this->plugin->get( 'settings' );
+        if ( $settings ) $tm->set_extra_stop_words( $settings->tms_stop_words() );
+        $tm->set_candidate_ids( $members );
+        _prime_post_caches( array_slice( $members, $cursor, 200 ), false, false );
+
+        $skipped = [];
+        foreach ( (array) $wpdb->get_results( "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_tms_skipped" ) as $r ) {
+            $skipped[ (int) $r->source_id . '_' . (int) $r->target_id ] = true;
+        }
+
+        $start   = microtime( true );
+        $matches = [];
+        $i       = $cursor;
+        for ( ; $i < $total; $i++ ) {
+            if ( microtime( true ) - $start > 4.0 ) break;     // time box — the client continues
+            $pid    = $members[ $i ];
+            $result = $tm->scan( $pid, max( 1, $total ), 0 );
+            if ( ! $result['matches'] ) continue;
+            $title  = html_entity_decode( wp_strip_all_tags( get_the_title( $pid ) ), ENT_QUOTES, 'UTF-8' ); // JSON: plain text, the client escapes it
+            foreach ( $result['matches'] as $m ) {
+                if ( isset( $skipped[ $pid . '_' . (int) $m['target_id'] ] ) ) continue;
+                $m['source_id']    = $pid;
+                $m['source_title'] = $title;
+                $matches[] = $m;
+            }
+        }
+        $tm->set_candidate_ids( null );
+
+        if ( $matches ) {
+            $ids  = array_values( array_unique( array_map( 'intval', array_column( $matches, 'target_id' ) ) ) );
+            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids ), OBJECT_K );
+            foreach ( $matches as &$mm ) {
+                $r = $rows[ $mm['target_id'] ] ?? null;
+                $mm['target_role']    = $r ? (string) $r->content_class : '';
+                $mm['target_inbound'] = $r ? (int) $r->inbound_links : 0;
+            }
+            unset( $mm );
+            $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' );
+            $matches = \Clusterlane\Core\Thumbs::add( $matches, 'source_id', 'source_thumb' );
+        }
+
+        return rest_ensure_response( [
+            'matches'     => $matches,
+            'scanned'     => $i,
+            'total'       => $total,
+            'next_cursor' => $i < $total ? $i : null,
+        ] );
+    }
+
+    /**
+     * POST tms/scan-batch { ids: [..], match_cat } → unlinked mentions for as
+     * many of the given source articles as fit in about 4 seconds, in order.
+     * Returns { results: [{post_id, post_title, matches}], processed }; the
+     * client continues from ids[processed].
+     */
+    public function scan_batch( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $p         = (array) $request->get_json_params();
+        $ids       = array_slice( array_values( array_filter( array_map( 'intval', (array) ( $p['ids'] ?? [] ) ) ) ), 0, 500 );
+        $match_cat = (int) ( $p['match_cat'] ?? 0 );
+        if ( ! $ids ) return new \WP_REST_Response( [ 'error' => 'Missing ids' ], 400 );
+
+        $tm       = $this->plugin->get( 'text_match' );
+        $settings = $this->plugin->get( 'settings' );
+        if ( $settings ) $tm->set_extra_stop_words( $settings->tms_stop_words() );
+        if ( $match_cat > 0 ) $tm->set_match_cat_id( $match_cat );
+        _prime_post_caches( $ids, false, false );
+
+        $skipped = [];
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT source_id, target_id FROM {$wpdb->prefix}clusterlane_tms_skipped WHERE source_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids ) ) as $r ) {
+            $skipped[ (int) $r->source_id . '_' . (int) $r->target_id ] = true;
+        }
+
+        $start = microtime( true ); $results = []; $all = []; $done = 0;
+        foreach ( $ids as $pid ) {
+            if ( $done > 0 && microtime( true ) - $start > 4.0 ) break; // time box — the client continues
+            $done++;
+            $res     = $tm->scan( $pid, PHP_INT_MAX, 0 );
+            $matches = array_values( array_filter( $res['matches'], fn( $m ) => ! isset( $skipped[ $pid . '_' . (int) $m['target_id'] ] ) ) );
+            if ( ! $matches ) continue;
+            foreach ( $matches as &$m ) { $m['source_id'] = $pid; }
+            unset( $m );
+            $results[] = [ 'post_id' => $pid, 'post_title' => html_entity_decode( wp_strip_all_tags( get_the_title( $pid ) ), ENT_QUOTES, 'UTF-8' ), 'matches' => $matches ];
+            array_push( $all, ...$matches );
+        }
+
+        // Target role + inbound links, and thumbnails — once for the whole batch.
+        if ( $all ) {
+            $tids = array_values( array_unique( array_map( 'intval', array_column( $all, 'target_id' ) ) ) );
+            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $tids ), '%d' ) ) . ")", $tids ), OBJECT_K );
+            $turl = \Clusterlane\Core\Thumbs::urls( $tids );
+            $surl = \Clusterlane\Core\Thumbs::urls( array_column( $results, 'post_id' ) );
+            foreach ( $results as &$g ) {
+                foreach ( $g['matches'] as &$m ) {
+                    $r = $rows[ $m['target_id'] ] ?? null;
+                    $m['target_role']    = $r ? (string) $r->content_class : '';
+                    $m['target_inbound'] = $r ? (int) $r->inbound_links : 0;
+                    $m['target_thumb']   = $turl[ (int) $m['target_id'] ] ?? '';
+                    $m['source_thumb']   = $surl[ (int) $g['post_id'] ] ?? '';
+                }
+                unset( $m );
+            }
+            unset( $g );
+        }
+
+        return rest_ensure_response( [ 'results' => $results, 'processed' => $done ] );
+    }
+
+    public function scan_post( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $post_id    = (int) ( $request->get_json_params()['post_id']    ?? 0 );
+        $batch_size = (int) ( $request->get_json_params()['batch_size'] ?? 0 );
+        if ( $batch_size <= 0 ) $batch_size = PHP_INT_MAX; // 0 = every article as a possible target
+        $offset     = (int) ( $request->get_json_params()['offset']     ?? 0 );
+        $match_cat  = (int) ( $request->get_json_params()['match_cat']  ?? 0 );
+
+        if ( ! $post_id ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing post_id' ], 400 );
+        }
+
+        // Load skipped pairs for this source post.
+        $skipped_table   = $wpdb->prefix . 'clusterlane_tms_skipped';
+        $skipped_targets = $wpdb->get_col( $wpdb->prepare(
+            'SELECT target_id FROM %i WHERE source_id = %d',
+            $skipped_table, $post_id
+        ) );
+        $skipped_targets = array_map( 'intval', $skipped_targets );
+
+        $post   = get_post( $post_id );
+        $tm     = $this->plugin->get('text_match');
+
+        // Inject site-specific stop words from Settings.
+        $settings = $this->plugin->get('settings');
+        if ( $settings ) {
+            $tm->set_extra_stop_words( $settings->tms_stop_words() );
+        }
+
+        // Restrict candidate pool to a specific category if requested.
+        if ( $match_cat > 0 ) {
+            $tm->set_match_cat_id( $match_cat );
+        }
+
+        $result = $tm->scan( $post_id, $batch_size, $offset );
+
+        // Filter out persistently skipped matches.
+        $matches = array_values( array_filter(
+            $result['matches'],
+            fn( $m ) => ! in_array( (int) $m['target_id'], $skipped_targets, true )
+        ) );
+
+        // Target role + inbound links: linking to an orphan is the most valuable fix.
+        if ( $matches ) {
+            $ids  = array_values( array_map( 'intval', array_column( $matches, 'target_id' ) ) );
+            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ")", $ids ), OBJECT_K );
+            foreach ( $matches as &$mm ) {
+                $r = $rows[ $mm['target_id'] ] ?? null;
+                $mm['target_role']    = $r ? (string) $r->content_class : '';
+                $mm['target_inbound'] = $r ? (int) $r->inbound_links : 0;
+            }
+            unset( $mm );
+        }
+
+        $matches = \Clusterlane\Core\Thumbs::add( $matches, 'target_id', 'target_thumb' );
+        if ( $matches ) {
+            $src_thumb = \Clusterlane\Core\Thumbs::url( $post_id );
+            foreach ( $matches as &$mm ) { $mm['source_thumb'] = $src_thumb; }
+            unset( $mm );
+        }
+
+        $next_offset = $offset + $result['scanned'];
+        $more        = $next_offset < $result['total_candidates'];
+
+        return rest_ensure_response( [
+            'post_id'    => $post_id,
+            'post_title' => $post ? $post->post_title : '',
+            'matches'    => $matches,
+            'more'       => $more,
+            'offset'     => $next_offset,
+            'total'      => $result['total_candidates'],
+        ] );
+    }
+
+    public function apply_link( \WP_REST_Request $request ): \WP_REST_Response {
+        $params     = $request->get_json_params();
+        $source_id  = (int)    ( $params['source_id']  ?? 0 );
+        $match_text = sanitize_text_field( $params['match_text']  ?? '' );
+        $target_url = esc_url_raw( $params['target_url'] ?? '' );
+
+        if ( ! $source_id || ! $match_text || ! $target_url ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing parameters' ], 400 );
+        }
+
+        // Verify that source post exists and is published.
+        $source_post = get_post( $source_id );
+        if ( ! $source_post || $source_post->post_status !== 'publish' ) {
+            return new \WP_REST_Response( [ 'error' => 'Source post not found' ], 404 );
+        }
+
+        // Object-level check: writing into an article requires edit rights on it.
+        if ( ! current_user_can( 'edit_post', $source_id ) ) {
+            return new \WP_REST_Response( [ 'error' => 'You are not allowed to edit this article' ], 403 );
+        }
+
+        // Page-builder articles render from the builder's own data — refuse
+        // instead of reporting a success that never shows on the site.
+        $builder = \Clusterlane\Core\Compat::page_builder( $source_id );
+        if ( $builder ) {
+            return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::page_builder_message( $builder ), 'page_builder' => $builder ], 409 );
+        }
+
+        $editing = \Clusterlane\Core\Compat::locked_by( $source_id );
+        if ( $editing ) {
+            return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::locked_message( $editing ) ], 409 );
+        }
+
+        // Reject external URLs — TMS only inserts internal links.
+        if ( strpos( $target_url, home_url() ) !== 0 || ! \Clusterlane\Core\Multisite::is_internal_url( $target_url ) ) {
+            return new \WP_REST_Response( [ 'error' => 'Target URL must be on this site' ], 400 );
+        }
+
+        $ok = $this->plugin->get('text_match')->apply_link( $source_id, $match_text, $target_url );
+
+        if ( ! $ok ) {
+            return new \WP_REST_Response( [
+                'error' => 'Could not apply link — text may already be linked or not found.',
+            ], 422 );
+        }
+
+        // Surface a non-blocking warning when the content was written in
+        // Gutenberg. The link IS inserted correctly in most cases, but
+        // block-editor posts should ideally be reviewed in the editor
+        // afterwards to confirm the change looks right.
+        $target = $this->refresh_after_change( $source_id, $target_url, ! empty( $params['quick'] ) );
+        \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::LINK_ADDED, 'text_match', $source_id, (int) ( $target['id'] ?? url_to_postid( $target_url ) ), $match_text );
+
+        $warning = has_blocks( $source_post->post_content )
+            ? __( 'This article uses the block editor. The link was applied — please verify it in the editor to make sure the formatting looks right.', 'clusterlane' )
+            : null;
+
+        return rest_ensure_response( [
+            'success' => true,
+            'message' => sprintf( 'Link applied: "%s" → %s', $match_text, $target_url ),
+            'warning' => $warning,
+            'target'  => $target,
+        ] );
+    }
+
+    /**
+     * POST clusterlane/v1/tms/unlink — Undo right after Apply.
+     */
+    public function unlink( \WP_REST_Request $request ): \WP_REST_Response {
+        $p          = $request->get_json_params();
+        $source_id  = (int) ( $p['source_id'] ?? 0 );
+        $match_text = (string) ( $p['match_text'] ?? '' );
+        $target_url = esc_url_raw( (string) ( $p['target_url'] ?? '' ) );
+        if ( ! $source_id || $match_text === '' || $target_url === '' ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing parameters' ], 400 );
+        }
+        if ( ! current_user_can( 'edit_post', $source_id ) ) {
+            return new \WP_REST_Response( [ 'error' => 'You are not allowed to edit this article' ], 403 );
+        }
+        $editing = \Clusterlane\Core\Compat::locked_by( $source_id );
+        if ( $editing ) {
+            return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\Compat::locked_message( $editing ) ], 409 );
+        }
+        if ( ! $this->plugin->get( 'text_match' )->remove_link( $source_id, $match_text, $target_url ) ) {
+            return new \WP_REST_Response( [ 'error' => __( 'The link could not be found any more — it may have been edited.', 'clusterlane' ) ], 409 );
+        }
+        $target = $this->refresh_after_change( $source_id, $target_url, ! empty( $p['quick'] ) );
+        \Clusterlane\Core\Activity::undo_last( \Clusterlane\Core\Activity::LINK_ADDED, $source_id, (int) ( $target['id'] ?? url_to_postid( $target_url ) ), 'text_match' );
+        return rest_ensure_response( [ 'success' => true, 'target' => $target ] );
+    }
+
+    /**
+     * Keep the link graph and the target's metrics current after Apply/Undo,
+     * so orphan counts and roles update without a new full scan.
+     *
+     * @return array{id:int, role:string, inbound:int}|null
+     */
+    private function refresh_after_change( int $source_id, string $target_url, bool $quick = false ): ?array {
+        $graph = $this->plugin->get( 'link_graph' );
+        if ( $graph ) {
+            $graph->save_links( $source_id, $graph->extract_links( $source_id ) );
+        }
+        $target_id = url_to_postid( $target_url );
+        if ( ! $target_id ) return null;
+        // Apply all (0.98.184): the link graph is up to date; the target's counts
+        // follow when Metrics runs at the end of the run (Scanner::refresh_counts),
+        // instead of re-reading its published page after every link.
+        if ( $quick ) return [ 'id' => $target_id ];
+        $scanner = $this->plugin->get( 'scanner' );
+        if ( $scanner ) $scanner->scan_post( $target_id, true );
+        global $wpdb;
+        $r = $wpdb->get_row( $wpdb->prepare( "SELECT content_class, inbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $target_id ) );
+        return [ 'id' => $target_id, 'role' => $r ? (string) $r->content_class : '', 'inbound' => $r ? (int) $r->inbound_links : 0 ];
+    }
+
+    public function skip_match( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $params    = $request->get_json_params();
+        $source_id = (int) ( $params['source_id'] ?? 0 );
+        $target_id = (int) ( $params['target_id'] ?? 0 );
+
+        if ( ! $source_id || ! $target_id ) {
+            return new \WP_REST_Response( [ 'error' => 'Missing source_id or target_id' ], 400 );
+        }
+
+        $table = $wpdb->prefix . 'clusterlane_tms_skipped';
+        $wpdb->query( $wpdb->prepare(
+            'INSERT IGNORE INTO %i (source_id, target_id, skipped_at)
+             VALUES (%d, %d, %s)',
+            $table, $source_id, $target_id, current_time( 'mysql', true )
+        ) );
+
+        return rest_ensure_response( [ 'success' => true ] );
+    }
+
+    public function clear_skipped( \WP_REST_Request $request ): \WP_REST_Response {
+        global $wpdb;
+        $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}clusterlane_tms_skipped" );
+        return rest_ensure_response( [ 'success' => true, 'message' => 'All skipped matches cleared.' ] );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-activity.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-activity.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-activity.php	(revision 3751027)
@@ -0,0 +1,180 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Activity — what was done through Clusterlane (0.98.124).
+ *
+ * One row per write that an editor approved: a link added (Text Match,
+ * Link Suggestions), a pillar chosen or cleared, an SEO field written, a
+ * draft created from Gap Analysis. Undo doesn't delete the row but sets
+ * undone_at, so reports count net ("38 links added") and can still show
+ * what was tried.
+ *
+ * The rows hold the WordPress user ID of the editor. When a user is
+ * deleted, their rows are kept for the totals but the ID is cleared.
+ */
+class Activity {
+
+    const LINK_ADDED     = 'link_added';
+    const PILLAR_CHOSEN  = 'pillar_chosen';
+    const PILLAR_CLEARED = 'pillar_cleared';
+    const META_WRITTEN   = 'meta_written';
+    const DRAFT_CREATED  = 'draft_created';
+
+    public static function register_hooks(): void {
+        add_action( 'deleted_user', [ self::class, 'forget_user' ] );
+        add_action( 'wpmu_delete_user', [ self::class, 'forget_user_network' ] );
+        add_action( 'admin_init', [ self::class, 'privacy_policy' ] );
+    }
+
+    /** Suggested text for Settings → Privacy (the log holds editors' user IDs). */
+    public static function privacy_policy(): void {
+        if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) return;
+        wp_add_privacy_policy_content( Brand::name(), wp_kses_post( wpautop(
+            __( 'This site uses Clusterlane to review internal links. Clusterlane keeps a log of the changes made through it — links added, SEO descriptions written, pillar articles chosen and drafts created — together with the WordPress user ID of the editor who approved each change. The log stays in this site\'s database and is not sent anywhere. When a user account is deleted, its user ID is removed from the log.', 'clusterlane' )
+        ) ) );
+    }
+
+    private static function table(): string {
+        global $wpdb;
+        return $wpdb->prefix . 'clusterlane_activity';
+    }
+
+    /**
+     * Record one write. $cluster_key defaults to the primary cluster of the
+     * target (links) or of the post (other actions). Returns the row ID, 0 on failure.
+     */
+    public static function log( string $action, string $tool, int $post_id, int $target_id = 0, string $detail = '', string $cluster_key = '' ): int {
+        global $wpdb;
+        if ( $cluster_key === '' ) {
+            $of          = $target_id ?: $post_id;
+            $cluster_key = (string) $wpdb->get_var( $wpdb->prepare(
+                "SELECT primary_cluster FROM `{$wpdb->prefix}clusterlane_post_metrics` WHERE post_id = %d", $of
+            ) );
+            if ( $cluster_key === '' ) { // not computed yet: any cluster the article belongs to
+                $cluster_key = (string) $wpdb->get_var( $wpdb->prepare(
+                    "SELECT cluster_key FROM `{$wpdb->prefix}clusterlane_cluster_post_class` WHERE post_id = %d ORDER BY id LIMIT 1", $of
+                ) );
+            }
+        }
+        $ok = $wpdb->insert( self::table(), [
+            'action'      => substr( sanitize_key( $action ), 0, 20 ),
+            'tool'        => substr( sanitize_key( $tool ), 0, 20 ),
+            'post_id'     => max( 0, $post_id ),
+            'target_id'   => max( 0, $target_id ),
+            'cluster_key' => substr( sanitize_key( $cluster_key ), 0, 100 ),
+            'detail'      => mb_substr( sanitize_text_field( $detail ), 0, 255 ),
+            'user_id'     => get_current_user_id(),
+            'created_at'  => current_time( 'mysql', true ),
+        ], [ '%s', '%s', '%d', '%d', '%s', '%s', '%d', '%s' ] );
+        return $ok ? (int) $wpdb->insert_id : 0;
+    }
+
+    /**
+     * Mark the latest matching, not yet undone row as undone.
+     * $target_id 0 matches any target (SEO fields have none).
+     */
+    public static function undo_last( string $action, int $post_id, int $target_id = 0, string $tool = '' ): bool {
+        global $wpdb;
+        $t     = self::table();
+        // target 0 = any target, tool '' = any tool.
+        $id = (int) $wpdb->get_var( $wpdb->prepare(
+            "SELECT id FROM %i WHERE action = %s AND post_id = %d AND undone_at IS NULL
+               AND ( %d = 0 OR target_id = %d ) AND ( %s = '' OR tool = %s )
+             ORDER BY id DESC LIMIT 1",
+            $t, $action, $post_id, $target_id, $target_id, $tool, $tool
+        ) );
+        if ( ! $id ) return false;
+        return (bool) $wpdb->update( $t, [ 'undone_at' => current_time( 'mysql', true ) ], [ 'id' => $id ], [ '%s' ], [ '%d' ] );
+    }
+
+    /**
+     * Net counts per action and tool between two UTC datetimes ('Y-m-d H:i:s').
+     * @return array{total:array<string,int>, by_tool:array<string,array<string,int>>, undone:int}
+     */
+    public static function summary( string $from, string $to ): array {
+        global $wpdb;
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            'SELECT action, tool, SUM(undone_at IS NULL) AS kept, SUM(undone_at IS NOT NULL) AS undone
+             FROM %i WHERE created_at >= %s AND created_at < %s GROUP BY action, tool',
+            self::table(), $from, $to
+        ) );
+        $out = [ 'total' => [], 'by_tool' => [], 'undone' => 0 ];
+        foreach ( (array) $rows as $r ) {
+            $out['total'][ $r->action ]              = ( $out['total'][ $r->action ] ?? 0 ) + (int) $r->kept;
+            $out['by_tool'][ $r->action ][ $r->tool ] = (int) $r->kept;
+            $out['undone']                           += (int) $r->undone;
+        }
+
+        // Pillars: clusters that got a new pillar, not medal clicks (0.98.144).
+        // A cluster whose pillar changed three times counts once; one whose
+        // choice was cleared again by the end of the period doesn't count.
+        $last = [];
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare(
+            "SELECT cluster_key, action, tool FROM %i
+             WHERE action IN (%s, %s) AND undone_at IS NULL AND cluster_key <> '' AND created_at >= %s AND created_at < %s ORDER BY id",
+            self::table(), self::PILLAR_CHOSEN, self::PILLAR_CLEARED, $from, $to
+        ) ) as $r ) {
+            $last[ $r->cluster_key ] = $r;
+        }
+        $chosen = array_filter( $last, static fn( $r ) => $r->action === self::PILLAR_CHOSEN );
+        $out['total'][ self::PILLAR_CHOSEN ]   = count( $chosen );
+        $out['by_tool'][ self::PILLAR_CHOSEN ] = [];
+        foreach ( $chosen as $r ) $out['by_tool'][ self::PILLAR_CHOSEN ][ $r->tool ] = ( $out['by_tool'][ self::PILLAR_CHOSEN ][ $r->tool ] ?? 0 ) + 1;
+        return $out;
+    }
+
+    /**
+     * Net count of one action per bin of $bin_days, from $from (UTC datetime)
+     * for $bins bins — for the "per week" bars.
+     * @return int[]
+     */
+    public static function binned( string $action, string $from, int $bins, int $bin_days = 7 ): array {
+        global $wpdb;
+        $out   = array_fill( 0, max( 1, $bins ), 0 );
+        $start = strtotime( $from . ' UTC' );
+        $to    = gmdate( 'Y-m-d H:i:s', $start + $bins * $bin_days * DAY_IN_SECONDS );
+        $times = $wpdb->get_col( $wpdb->prepare(
+            'SELECT created_at FROM %i WHERE action = %s AND undone_at IS NULL AND created_at >= %s AND created_at < %s',
+            self::table(), $action, $from, $to
+        ) );
+        foreach ( (array) $times as $t ) {
+            $i = (int) floor( ( strtotime( $t . ' UTC' ) - $start ) / ( $bin_days * DAY_IN_SECONDS ) );
+            if ( isset( $out[ $i ] ) ) $out[ $i ]++;
+        }
+        return $out;
+    }
+
+    /** @return array{count:int, first:?string} For the support report. */
+    public static function stats(): array {
+        global $wpdb;
+        $r = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS n, MIN(created_at) AS first FROM %i', self::table() ) );
+        return [ 'count' => (int) ( $r->n ?? 0 ), 'first' => $r->first ?? null ];
+    }
+
+    /** A deleted user's rows stay for the totals, without their ID. */
+    public static function forget_user( int $user_id ): void {
+        global $wpdb;
+        $wpdb->update( self::table(), [ 'user_id' => 0 ], [ 'user_id' => $user_id ], [ '%d' ], [ '%d' ] );
+    }
+
+    /** Multisite: a user deleted from the network — every site they belonged to. */
+    public static function forget_user_network( int $user_id ): void {
+        foreach ( array_keys( get_blogs_of_user( $user_id ) ) as $blog_id ) {
+            switch_to_blog( (int) $blog_id );
+            global $wpdb;
+            if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', self::table() ) ) === self::table() ) {
+                self::forget_user( $user_id );
+            }
+            restore_current_blog();
+        }
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-brand.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-brand.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-brand.php	(revision 3751027)
@@ -0,0 +1,180 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Brand — everything that says "Clusterlane" to the user, in one place.
+ *
+ * Prepared for white label (Agency): an agency can show the plugin under its
+ * own name, logo and links. Today the defaults are Clusterlane's; a later
+ * settings page (after Freemius) will set them. Until then they can be set
+ * with the `clusterlane_brand` filter, for example in a small mu-plugin:
+ *
+ *     add_filter( 'clusterlane_brand', fn( $b ) => array_merge( $b, [
+ *         'name'          => 'Agency SEO Insights',
+ *         'logo_url'      => 'https://agency.example/logo.svg', // square, shown at 18–40 px
+ *         'site_url'      => 'https://agency.example',
+ *         'docs_url'      => 'https://agency.example/help/',
+ *         'show_upsell'   => false,  // hide "See Pro", upgrade boxes and PRO badges
+ *         'show_vendor'   => false,  // hide links to clusterlane.app
+ *     ] ) );
+ *
+ * What follows the brand: the admin menu name and icon, page titles, the
+ * dock and Dashboard logo, notices, the support report, drafts created from
+ * briefs, and — when the name differs — every translated text that says
+ * "Clusterlane" (via the gettext filter for this text domain). JavaScript gets
+ * the same values as window.clusterlaneBrand.
+ *
+ * Internal names are not branded: the text domain, the clusterlane_ prefix, REST
+ * routes and the AI prompts stay as they are.
+ */
+final class Brand {
+
+    const DEFAULTS = [
+        'name'        => 'Clusterlane',
+        'logo_url'    => '',                               // '' = the built-in station mark
+        'site_url'    => 'https://clusterlane.app',
+        'docs_url'    => 'https://clusterlane.app/docs/',
+        'guides_url'  => 'https://clusterlane.app/guides/',
+        'pricing_url' => 'https://clusterlane.app/pricing/',
+        'support_url' => 'https://clusterlane.app/',
+        'show_upsell' => true,
+        'show_vendor' => true,
+    ];
+
+    /** Line and ring colours of the mark (the plugin's Pillar orange and ink). */
+    const ORANGE = '#E76C2E';
+    const INK    = '#16223A';
+
+    private static ?array $brand = null;
+
+    /** The brand, filtered and sanitized once per request. */
+    public static function get(): array {
+        if ( self::$brand !== null ) return self::$brand;
+        $b = (array) apply_filters( 'clusterlane_brand', self::DEFAULTS );
+        $b = array_merge( self::DEFAULTS, array_intersect_key( $b, self::DEFAULTS ) );
+        foreach ( [ 'logo_url', 'site_url', 'docs_url', 'guides_url', 'pricing_url', 'support_url' ] as $k ) {
+            $b[ $k ] = $b[ $k ] !== '' ? esc_url_raw( (string) $b[ $k ] ) : '';
+        }
+        $b['name']        = trim( wp_strip_all_tags( (string) $b['name'] ) ) ?: self::DEFAULTS['name'];
+        $b['show_upsell'] = (bool) $b['show_upsell'];
+        $b['show_vendor'] = (bool) $b['show_vendor'];
+        return self::$brand = $b;
+    }
+
+    /** Forget the cached brand (tests, or after a settings change in the same request). */
+    public static function reset(): void {
+        self::$brand = null;
+    }
+
+    public static function name(): string {
+        return self::get()['name'];
+    }
+
+    /** True when the plugin is shown under another name than Clusterlane. */
+    public static function is_white_label(): bool {
+        return self::name() !== self::DEFAULTS['name'];
+    }
+
+    /** site | docs | guides | pricing | support — '' when vendor links are hidden. */
+    public static function url( string $which ): string {
+        $b   = self::get();
+        $url = (string) ( $b[ $which . '_url' ] ?? '' );
+        // Pricing keeps its older filter (Freemius hooks in here).
+        if ( $which === 'pricing' ) $url = (string) apply_filters( 'clusterlane_upgrade_url', $url );
+        if ( ! $b['show_vendor'] && str_starts_with( $url, self::DEFAULTS['site_url'] ) ) return '';
+        return $url;
+    }
+
+    public static function show_upsell(): bool {
+        return self::get()['show_upsell'];
+    }
+
+    /**
+     * The mark: a station (ring) on a line — the same mark as on clusterlane.app.
+     * $ring is the ring's stroke, $fill its centre (the background it sits on).
+     * With a custom logo_url, an <img> of that logo is returned instead.
+     */
+    public static function mark( int $size = 24, string $ring = self::INK, string $fill = '#fff' ): string {
+        $logo = self::get()['logo_url'];
+        if ( $logo !== '' ) {
+            return '<img class="clusterlane-brand-logo" src="' . esc_url( $logo ) . '" width="' . (int) $size . '" height="' . (int) $size . '" alt="" aria-hidden="true">';
+        }
+        return '<svg class="clusterlane-brand-mark" xmlns="http://www.w3.org/2000/svg" width="' . (int) $size . '" height="' . (int) $size . '" viewBox="0 0 30 30" aria-hidden="true" focusable="false">'
+            . '<path d="M2 15h26" stroke="' . esc_attr( self::ORANGE ) . '" stroke-width="6" stroke-linecap="round"/>'
+            . '<circle cx="15" cy="15" r="8" fill="' . esc_attr( $fill ) . '" stroke="' . esc_attr( $ring ) . '" stroke-width="4"/>'
+            . '</svg>';
+    }
+
+    /** Allowed markup for mark() — for wp_kses(). */
+    public static function kses(): array {
+        return [
+            'svg'    => [ 'class' => true, 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'aria-hidden' => true, 'focusable' => true ],
+            'path'   => [ 'd' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'fill' => true ],
+            'circle' => [ 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ],
+            'img'    => [ 'class' => true, 'src' => true, 'width' => true, 'height' => true, 'alt' => true, 'aria-hidden' => true ],
+        ];
+    }
+
+    /** Print the mark, escaped. */
+    public static function mark_e( int $size = 24, string $ring = self::INK, string $fill = '#fff' ): void {
+        echo wp_kses( self::mark( $size, $ring, $fill ), self::kses() );
+    }
+
+    /**
+     * Admin menu icon: the mark in one colour ("black" is the WordPress
+     * convention — the admin colour scheme tints it). A custom logo is used
+     * as is.
+     */
+    public static function menu_icon(): string {
+        $logo = self::get()['logo_url'];
+        if ( $logo !== '' ) return $logo;
+        $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">'
+            . '<path d="M1.5 10h4.2M14.3 10h4.2" stroke="black" stroke-width="3" stroke-linecap="round"/>'
+            . '<circle cx="10" cy="10" r="4.6" fill="none" stroke="black" stroke-width="2.6"/>'
+            . '</svg>';
+        return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data URI for the menu icon
+    }
+
+    /** For JavaScript (window.clusterlaneBrand). */
+    public static function to_js(): array {
+        return [
+            'name'       => self::name(),
+            'whiteLabel' => self::is_white_label(),
+            'showUpsell' => self::show_upsell(),
+            'urls'       => [
+                'site'    => self::url( 'site' ),
+                'docs'    => self::url( 'docs' ),
+                'guides'  => self::url( 'guides' ),
+                'pricing' => self::url( 'pricing' ),
+                'support' => self::url( 'support' ),
+            ],
+        ];
+    }
+
+    /**
+     * Under another name, translated texts that say "Clusterlane" say that
+     * name instead — one filter for this text domain, no per-string work.
+     */
+    public static function register_hooks(): void {
+        if ( ! self::is_white_label() ) return;
+        $swap = fn( $text ) => str_replace( self::DEFAULTS['name'], self::name(), (string) $text );
+        add_filter( 'gettext_clusterlane', $swap, 20 );
+        add_filter( 'ngettext_clusterlane', $swap, 20 );
+        add_filter( 'gettext_with_context_clusterlane', $swap, 20 );
+
+        // The Plugins screen shows the brand too (the support report keeps the
+        // real plugin name on purpose — it is for support).
+        add_filter( 'all_plugins', function ( $plugins ) {
+            $file = plugin_basename( CLUSTERLANE_FILE );
+            if ( isset( $plugins[ $file ] ) ) {
+                $plugins[ $file ]['Name']      = self::name();
+                $plugins[ $file ]['Title']     = self::name();
+                $plugins[ $file ]['PluginURI'] = self::url( 'site' );
+                if ( ! self::get()['show_vendor'] ) { $plugins[ $file ]['Author'] = ''; $plugins[ $file ]['AuthorURI'] = ''; $plugins[ $file ]['AuthorName'] = ''; }
+            }
+            return $plugins;
+        } );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-capabilities.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-capabilities.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-capabilities.php	(revision 3751027)
@@ -0,0 +1,230 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Capabilities
+ *
+ * Clusterlane's own capability model, replacing the generic
+ * edit_posts / manage_options checks.
+ *
+ *   manage_clusterlane — configure the plugin: Settings (incl. API key and
+ *                      Danger Zone), Cluster Admin, onboarding, reset,
+ *                      custom clusters, AI cluster suggestions.
+ *   use_clusterlane    — work with the plugin: Dashboard, Inspectors,
+ *                      Gap Analysis, Text Match, Link Suggestions, Help.
+ *
+ * manage_clusterlane implies use_clusterlane (see can_use()).
+ *
+ *   clusterlane_add_links / clusterlane_write_seo — write through Clusterlane:
+ *                      links in article text, SEO title & description.
+ *                      Set per role in Settings → General (0.98.132).
+ *
+ * Default mapping (filterable via 'clusterlane_role_capabilities'):
+ *   administrator → all
+ *   editor        → use_clusterlane + AI + add links + write SEO
+ *
+ * Typical agency setup: agency = administrator, client = editor.
+ *
+ * Roles are stored per site, so on multisite each sub-site gets its caps
+ * the first time the plugin boots there (maybe_install()).
+ */
+final class Capabilities {
+
+    const MANAGE = 'manage_clusterlane';
+    const USE    = 'use_clusterlane';
+    /** Run NEW AI calls (cached results need only USE). Since mapping v2. */
+    const USE_AI = 'use_clusterlane_ai';
+    /** Add links to article text (Text Match, Link Suggestions). Since mapping v3. */
+    const LINKS  = 'clusterlane_add_links';
+    /** Write SEO title & meta description (SEO AI). Since mapping v3. */
+    const SEO    = 'clusterlane_write_seo';
+
+    /** Mapping version each capability was introduced in. */
+    const SINCE = [ self::MANAGE => 1, self::USE => 1, self::USE_AI => 2, self::LINKS => 3, self::SEO => 3 ];
+
+    /** The per-role write permissions shown in Settings → Access (0.98.132). */
+    const WRITE_CAPS = [ self::LINKS, self::SEO ];
+
+    /** Option storing which version of the role mapping has been applied. */
+    const VERSION_KEY = 'clusterlane_caps_version';
+
+    /** Bump when the default mapping changes, to re-apply on existing sites. */
+    const VERSION = '3';
+
+    /**
+     * @return array<string, string[]> role slug => capabilities to grant
+     */
+    public static function role_map(): array {
+        $map = [
+            'administrator' => [ self::MANAGE, self::USE, self::USE_AI, self::LINKS, self::SEO ],
+            'editor'        => [ self::USE, self::USE_AI, self::LINKS, self::SEO ],
+        ];
+        return (array) apply_filters( 'clusterlane_role_capabilities', $map );
+    }
+
+    /**
+     * Grant default capabilities. Additive only, and on an upgrade only the
+     * capabilities introduced AFTER $from_version — so a cap an admin took
+     * away on purpose (e.g. use_clusterlane from editors) is never re-added.
+     */
+    public static function install( int $from_version = 0 ): void {
+        foreach ( self::role_map() as $role_slug => $caps ) {
+            $role = get_role( $role_slug );
+            if ( ! $role ) continue;
+            foreach ( (array) $caps as $cap ) {
+                if ( isset( self::SINCE[ $cap ] ) && self::SINCE[ $cap ] > $from_version ) {
+                    $role->add_cap( $cap );
+                }
+            }
+        }
+        // v3: the write permissions are new, but until now everyone with
+        // Clusterlane access could add links and write SEO fields. Keep that
+        // for every role that has access (also custom roles an admin gave
+        // use_clusterlane), so nothing changes until an admin unticks a role.
+        if ( $from_version > 0 && $from_version < 3 ) {
+            foreach ( wp_roles()->role_objects as $role ) {
+                if ( $role->has_cap( self::USE ) || $role->has_cap( self::MANAGE ) ) {
+                    foreach ( self::WRITE_CAPS as $cap ) $role->add_cap( $cap );
+                }
+            }
+        }
+        update_option( self::VERSION_KEY, self::VERSION, false );
+    }
+
+    /**
+     * Runs on every boot; only touches roles when the mapping version
+     * differs. Never removes caps — so an admin who deliberately took
+     * use_clusterlane away from editors (via a role editor) keeps that choice.
+     */
+    public static function maybe_install(): void {
+        $installed = get_option( self::VERSION_KEY );
+        if ( $installed === self::VERSION ) return;
+        self::install( (int) $installed );
+    }
+
+    /** Remove both capabilities from every role on the current site. */
+    public static function remove_all(): void {
+        $roles = wp_roles();
+        foreach ( $roles->role_objects as $role ) {
+            $role->remove_cap( self::MANAGE );
+            $role->remove_cap( self::USE );
+            $role->remove_cap( self::USE_AI );
+            foreach ( self::WRITE_CAPS as $cap ) $role->remove_cap( $cap );
+        }
+        delete_option( self::VERSION_KEY );
+    }
+
+    /**
+     * Lock-out safety net: anyone with manage_options always has both
+     * Clusterlane caps, even if a role editor stripped them or the mapping
+     * never ran. Disable with add_filter( 'clusterlane_admin_cap_fallback', '__return_false' ).
+     */
+    public static function register_fallback(): void {
+        add_filter( 'user_has_cap', [ self::class, 'fallback' ], 10, 1 );
+    }
+
+    /**
+     * @param array<string, bool> $allcaps
+     * @return array<string, bool>
+     */
+    public static function fallback( array $allcaps ): array {
+        if ( ! empty( $allcaps['manage_options'] ) && apply_filters( 'clusterlane_admin_cap_fallback', true ) ) {
+            $allcaps[ self::MANAGE ] = true;
+            $allcaps[ self::USE ]    = true;
+            $allcaps[ self::USE_AI ] = true;
+            $allcaps[ self::LINKS ]  = true;
+            $allcaps[ self::SEO ]    = true;
+        }
+        return $allcaps;
+    }
+
+    /* ------------------------------------------------------------------
+       Checks — use these everywhere instead of raw current_user_can()
+    ------------------------------------------------------------------ */
+
+    public static function can_manage(): bool {
+        return current_user_can( self::MANAGE );
+    }
+
+    public static function can_use(): bool {
+        return current_user_can( self::USE ) || current_user_can( self::MANAGE );
+    }
+
+    /** May run NEW AI calls (cached results only need can_use()). */
+    public static function can_use_ai(): bool {
+        return current_user_can( self::USE_AI ) || current_user_can( self::MANAGE );
+    }
+
+    /** May add links to article text through Clusterlane (Text Match, Link Suggestions). */
+    public static function can_add_links(): bool {
+        return self::can_use() && ( current_user_can( self::LINKS ) || current_user_can( self::MANAGE ) );
+    }
+
+    /** May write SEO title / meta description through Clusterlane. */
+    public static function can_write_seo(): bool {
+        return self::can_use() && ( current_user_can( self::SEO ) || current_user_can( self::MANAGE ) );
+    }
+
+    /** REST permission for writes that add or remove links: true, or an error that says why. */
+    public static function rest_links(): bool|\WP_Error {
+        if ( ! self::can_use() ) return false;
+        return self::can_add_links() ? true : new \WP_Error( 'clusterlane_no_link_rights', self::no_right_message( self::LINKS ), [ 'status' => 403 ] );
+    }
+
+    /** REST permission for writing SEO fields: true, or an error that says why. */
+    public static function rest_seo(): bool|\WP_Error {
+        if ( ! self::can_use() ) return false;
+        return self::can_write_seo() ? true : new \WP_Error( 'clusterlane_no_seo_rights', self::no_right_message( self::SEO ), [ 'status' => 403 ] );
+    }
+
+    /** Plain words for a role that may look but not write. */
+    public static function no_right_message( string $cap ): string {
+        return $cap === self::SEO
+            ? __( 'Your role can see SEO suggestions but not write SEO titles or descriptions. Copy a suggestion, or ask an administrator.', 'clusterlane' )
+            : __( 'Your role can review link suggestions but not add links to articles. Ask an administrator.', 'clusterlane' );
+    }
+
+    /** @return array{links:bool, seo:bool, manage:bool} For page scripts (clusterlaneCan()). */
+    public static function for_js(): array {
+        return [ 'links' => self::can_add_links(), 'seo' => self::can_write_seo(), 'manage' => self::can_manage() ];
+    }
+
+    /**
+     * Role-aware "missing API key" message. Both variants start with
+     * "No AI API key" — the JS detects that prefix to decide on UI.
+     * Only managers are pointed to Settings, since editors can't open it.
+     */
+    public static function no_ai_key_message(): string {
+        // A key is saved but can't be decrypted (AUTH_KEY changed) — say so, since
+        // Settings shows it as stored (0.98.174). Still starts with "No AI API key".
+        $settings = Settings::get_instance();
+        if ( $settings && $settings->ai_key_unreadable() ) {
+            if ( $settings->ai_source() === 'network' ) {
+                return __( 'No AI API key can be used: the network\'s saved key can no longer be decrypted (the WordPress security keys have changed). Ask the network administrator to enter it again under Network Admin → Clusterlane.', 'clusterlane' );
+            }
+            return self::can_manage()
+                ? __( 'No AI API key can be used: the saved key can no longer be decrypted (the WordPress security keys in wp-config.php have changed). Enter it again under Settings → AI.', 'clusterlane' )
+                : __( 'No AI API key can be used: the saved key can no longer be decrypted. Ask an administrator to enter it again in Clusterlane Settings.', 'clusterlane' );
+        }
+        // Network manages AI and sites can't add their own key.
+        if ( class_exists( NetworkAi::class ) && NetworkAi::enabled() && ! NetworkAi::site_keys_allowed() ) {
+            return __( 'No AI API key configured. Ask the network administrator to add one under Network Admin → Clusterlane.', 'clusterlane' );
+        }
+        return self::can_manage()
+            ? __( 'No AI API key configured. Add one under Settings → AI.', 'clusterlane' )
+            : __( 'No AI API key configured. Ask an administrator to add one in Clusterlane Settings.', 'clusterlane' );
+    }
+
+    /**
+     * 400 response for AI endpoints when no usable key exists, else null.
+     * Endpoints call this right before the AI call — after their cache lookup —
+     * so saved results stay visible without a working key (0.98.174).
+     */
+    public static function no_ai_key_response(): ?\WP_REST_Response {
+        $settings = Settings::get_instance() ?? new Settings();
+        if ( $settings->ai_api_key() !== '' ) return null;
+        return new \WP_REST_Response( [ 'error' => self::no_ai_key_message() ], 400 );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-cluster-engine.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-cluster-engine.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-cluster-engine.php	(revision 3751027)
@@ -0,0 +1,776 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+require_once __DIR__ . '/class-cluster-types.php';
+require_once __DIR__ . '/class-hub-clusters.php';
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * ClusterEngine
+ *
+ * Builds clusters from WordPress taxonomies and computes cluster-level metrics
+ * (Health, Archetype, Entropy, Gini, Density) based on data in clusterlane_post_metrics
+ * and clusterlane_links.
+ */
+class ClusterEngine {
+
+    /* ------------------------------------------------------------------
+       Content-classification tuning (cluster-relative role assignment)
+
+       These gate classify_relative(). They exist so a post can't be
+       promoted to "pillar" — or dismissed as "light" — on percentile
+       alone, which is what let large ties of weakly-linked siblings all
+       score as pillars (e.g. inbound [1,1,1,1,5] → the four 1-link posts
+       each land at the 80th percentile because 4 of 5 members are <= 1).
+    ------------------------------------------------------------------ */
+
+    /** Below this member count, percentile splits are noise; use a simpler rule. */
+    const MIN_CLUSTER_FOR_PERCENTILE = 4;
+
+    /** A post needs at least this many inbound links to qualify as a pillar. */
+    const PILLAR_MIN_INBOUND = 2;
+
+    /** A post with more than this many inbound links is never "light". */
+    const LIGHT_MAX_INBOUND = 1;
+
+    /**
+     * Pillars per cluster (0.98.108). The top quarter by inbound links used
+     * to become pillars — 7 of 20, 13 of 43 — which flattened the word to
+     * nothing. Now at most 1 pillar, or 2 in clusters of this size or more;
+     * the rest of the top quarter are supports. A chosen pillar (Cluster
+     * Inspector → "Make pillar") is the cluster's only pillar.
+     */
+    const SECOND_PILLAR_FROM = 20;
+
+    /** Option: cluster_key → post ID of the pillar the editor chose. */
+    const CHOSEN_PILLARS_OPTION = 'clusterlane_chosen_pillars';
+
+    /** The archetype of every product-category cluster (0.98.172). */
+    const PRODUCT_ARCHETYPE = 'Product Range';
+
+    /** @return array<string,int> */
+    public static function chosen_pillars(): array {
+        $v = get_option( self::CHOSEN_PILLARS_OPTION, [] );
+        return is_array( $v ) ? array_map( 'intval', $v ) : [];
+    }
+
+    public static function chosen_pillar( string $cluster_key ): int {
+        return (int) ( self::chosen_pillars()[ $cluster_key ] ?? 0 );
+    }
+
+    /** Set (post ID) or clear (0) the chosen pillar of a cluster. */
+    public static function set_chosen_pillar( string $cluster_key, int $post_id ): void {
+        $all = self::chosen_pillars();
+        if ( $post_id > 0 ) $all[ $cluster_key ] = $post_id; else unset( $all[ $cluster_key ] );
+        update_option( self::CHOSEN_PILLARS_OPTION, $all, false );
+    }
+
+    /* ------------------------------------------------------------------
+       Cluster sync
+    ------------------------------------------------------------------ */
+
+    /**
+     * Sync clusters from all configured taxonomies.
+     * Respects min_term_count threshold — unless the user has explicitly
+     * picked individual categories/tags on the Cluster Admin page, in
+     * which case that selection is authoritative for those two
+     * taxonomies (any other enabled taxonomy still falls back to the
+     * min_term_count behavior).
+     * Returns number of new clusters created.
+     */
+    public function sync_all(): int {
+        $settings   = \Clusterlane\Core\Settings::get_instance();
+        $taxonomies = $settings ? $settings->taxonomies() : ['category'];
+        $min_count  = $settings ? $settings->min_term_count() : 1;
+        $configured = $settings ? $settings->cluster_terms_configured() : false;
+
+        // Cluster Admin is authoritative: tags picked there are built even if
+        // "Tags" isn't ticked under Settings → Scan Settings → Taxonomies
+        // (that hidden dependency made tag selections silently do nothing).
+        if ( $configured && $settings->enabled_tags() && ! in_array( 'post_tag', $taxonomies, true ) ) {
+            $taxonomies[] = 'post_tag';
+        }
+        // Product categories picked in Cluster Admin (WooCommerce, 0.98.172). Without
+        // WooCommerce their clusters are switched off, not deleted.
+        if ( Shop::active() ) {
+            if ( $configured && ! in_array( Shop::TAXONOMY, $taxonomies, true ) ) $taxonomies[] = Shop::TAXONOMY;
+        } else {
+            $taxonomies = array_values( array_diff( $taxonomies, [ Shop::TAXONOMY ] ) );
+            global $wpdb;
+            $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0 WHERE cluster_type = %s", Shop::TAXONOMY ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+        }
+
+        // Other taxonomies picked in Cluster Admin or onboarding (0.98.177): the
+        // selection is authoritative, like categories and tags.
+        $extra = $settings ? $settings->enabled_terms() : [];
+        $taxonomies = array_values( array_diff( $taxonomies, array_keys( $extra ) ) );
+
+        $created = 0;
+        foreach ( $extra as $taxonomy => $ids ) {
+            if ( ! taxonomy_exists( $taxonomy ) ) continue;
+            $created += $this->sync_from_taxonomy( $taxonomy, $min_count, $ids );
+        }
+        // Clusters of a taxonomy that is no longer picked — or no longer exists (its
+        // plugin was switched off) — are switched off, not deleted.
+        global $wpdb;
+        $known = array_merge( ClusterTypes::BUILT_IN, array_keys( array_filter( $extra, fn( $ids, $t ) => taxonomy_exists( $t ), ARRAY_FILTER_USE_BOTH ) ), $taxonomies );
+        $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0 WHERE cluster_type NOT IN (" . implode( ',', array_fill( 0, count( $known ), '%s' ) ) . ')', $known ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+
+        foreach ( $taxonomies as $taxonomy ) {
+            $allowed = null;
+            if ( $configured && $taxonomy === 'category' ) {
+                $allowed = $settings->enabled_categories();
+            } elseif ( $configured && $taxonomy === 'post_tag' ) {
+                $allowed = $settings->enabled_tags();
+            } elseif ( $taxonomy === Shop::TAXONOMY ) {
+                $allowed = $settings->enabled_product_cats(); // only what was picked — never every product category
+            }
+            $created += $this->sync_from_taxonomy( $taxonomy, $min_count, $allowed );
+        }
+        // Pages as clusters (0.98.180): after the switch-off above, which doesn't know them.
+        $created += HubClusters::sync();
+        return $created;
+    }
+
+    /**
+     * Sync clusters from a single taxonomy.
+     * Only creates clusters for terms with at least $min_count posts,
+     * unless $allowed_term_ids is given — an explicit selection from the
+     * Cluster Admin page, which is then authoritative (bypasses
+     * min_count; the settings UI already only lists terms with >= 2
+     * posts) and also DEACTIVATES any existing cluster of this taxonomy
+     * whose term isn't in the selection, so unchecking a category/tag
+     * there actually removes it from the dashboard.
+     */
+    public function sync_from_taxonomy( string $taxonomy = 'category', int $min_count = 1, ?array $allowed_term_ids = null ): int {
+        global $wpdb;
+
+        $args = [
+            'taxonomy'   => $taxonomy,
+            'hide_empty' => true,
+            'number'     => 0,
+            'orderby'    => 'count',
+            'order'      => 'DESC',
+        ];
+        if ( $allowed_term_ids !== null ) {
+            $args['include'] = ! empty( $allowed_term_ids ) ? $allowed_term_ids : [ 0 ]; // [0] never matches — deliberate "select none"
+        } else {
+            $args['min_count'] = $min_count;
+        }
+
+        $terms = get_terms( $args );
+
+        if ( is_wp_error( $terms ) ) return 0;
+
+        $seen_term_ids = [];
+        $created = 0;
+        foreach ( $terms as $term ) {
+            if ( $allowed_term_ids === null && (int) $term->count < $min_count ) continue;
+            $seen_term_ids[] = (int) $term->term_id;
+
+            $key = $taxonomy . '_' . $term->term_id;
+
+            $existing = $wpdb->get_var( $wpdb->prepare(
+                "SELECT id FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s",
+                $key
+            ) );
+
+            // WordPress stores term names with HTML entities pre-encoded
+            // (e.g. "Food &amp; Drink") — decode once here so cluster_label
+            // holds raw text, matching what every consumer (esc_html() in
+            // the dashboard table, escHtml() in JS) already assumes it can
+            // safely escape itself. Skipping this meant an ampersand in a
+            // category name rendered as a literal "&amp;" on screen.
+            $label = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' );
+
+            if ( $existing ) {
+                $wpdb->update(
+                    $wpdb->prefix . 'clusterlane_clusters',
+                    [
+                        'cluster_label' => $label,
+                        'term_id'       => $term->term_id,
+                        'is_active'     => 1,
+                    ],
+                    [ 'cluster_key' => $key ]
+                );
+            } else {
+                $wpdb->insert( $wpdb->prefix . 'clusterlane_clusters', [
+                    'cluster_key'   => $key,
+                    'cluster_label' => $label,
+                    'cluster_type'  => $taxonomy,
+                    'term_id'       => $term->term_id,
+                    'is_active'     => 1,
+                ] );
+                $created++;
+            }
+        }
+
+        if ( $allowed_term_ids !== null ) {
+            $keep = $seen_term_ids ? array_map( 'intval', $seen_term_ids ) : [ 0 ];
+            $wpdb->query( $wpdb->prepare(
+                "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0
+                 WHERE cluster_type = %s AND term_id NOT IN (" . implode( ',', array_fill( 0, count( $keep ), '%d' ) ) . ')',
+                array_merge( [ $taxonomy ], $keep )
+            ) );
+        }
+
+        return $created;
+    }
+
+    /* ------------------------------------------------------------------
+       Compute metrics for one cluster
+    ------------------------------------------------------------------ */
+
+    /**
+     * Compute and persist Health, Archetype and structural metrics
+     * for a single cluster identified by its cluster_key.
+     */
+    public function compute_cluster( string $cluster_key ): void {
+        global $wpdb;
+
+        $cluster = $wpdb->get_row( $wpdb->prepare(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s",
+            $cluster_key
+        ) );
+
+        if ( ! $cluster ) return;
+
+        // Get all post IDs in this cluster.
+        $post_ids = $this->get_member_ids( (int) $cluster->term_id, $cluster->cluster_type, $cluster_key );
+        $total    = count( $post_ids );
+
+        if ( $total === 0 ) return;
+
+        // Fetch metrics for cluster members.
+        $post_ids        = array_map( 'intval', $post_ids );
+        $metrics         = $wpdb->get_results(
+            $wpdb->prepare(
+                "SELECT post_id, overall_score, inbound_links, outbound_links
+                 FROM {$wpdb->prefix}clusterlane_post_metrics
+                 WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")",
+                $post_ids
+            )
+        );
+
+        if ( empty( $metrics ) ) return;
+
+        // --- Basic stats ---
+        $avg_overall  = round( array_sum( array_column( (array) $metrics, 'overall_score' ) ) / count( $metrics ) );
+        $inbound_vals = array_column( (array) $metrics, 'inbound_links' );
+        $orphan_count = count( array_filter( $inbound_vals, fn($v) => (int)$v === 0 ) );
+        $orphan_ratio = $total > 0 ? $orphan_count / $total : 0;
+
+        // --- Inbound link count for each member ---
+        $inbound_sum = array_sum( $inbound_vals );
+
+        // --- Density: avg outbound within cluster ---
+        // Count links between cluster members only.
+        $internal_links = (int) $wpdb->get_var(
+            $wpdb->prepare(
+                "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links
+                 WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")
+                 AND target_type = 'post'",
+                array_merge( $post_ids, $post_ids )
+            )
+        );
+        $max_links  = $total > 1 ? $total * ( $total - 1 ) : 1;
+        $density    = round( ( $internal_links / $max_links ) * 100 );
+
+        // --- Gini coefficient (inequality of inbound distribution) ---
+        $gini = $this->gini( $inbound_vals );
+
+        // --- Entropy (topic breadth — based on category overlap) ---
+        $entropy = $this->entropy_score( $post_ids );
+
+        // --- Health score ---
+        $health = $this->health(
+            (int) $avg_overall, $orphan_ratio, (int) $density, $gini, $entropy, $total
+        );
+
+        // --- Archetype ---
+        $archetype = $this->archetype( $entropy, $gini, (int) $density, $orphan_ratio, $total, (int) $avg_overall );
+
+        // WooCommerce product categories (0.98.172): products are not meant to link to each
+        // other, so density, evenness and breadth say nothing about a shop. What matters:
+        // good product pages (their own score) and articles linking to them (share of
+        // products with at least one link pointing to them). Half each.
+        if ( $cluster->cluster_type === Shop::TAXONOMY ) {
+            $health    = (int) max( 0, min( 100, round( $avg_overall * 0.5 + ( 1 - $orphan_ratio ) * 50 ) ) );
+            $archetype = self::PRODUCT_ARCHETYPE;
+        }
+
+        // Persist.
+        $wpdb->update(
+            $wpdb->prefix . 'clusterlane_clusters',
+            [
+                'total_members' => $total,
+                'health_score'  => $health,
+                'entropy_score' => $entropy,
+                'gini_score'    => $gini,
+                'density_score' => $density,
+                'archetype'     => $archetype,
+                'last_computed' => current_time( 'mysql' ),
+            ],
+            [ 'cluster_key' => $cluster_key ]
+        );
+
+        // Update primary_cluster on each member's metrics row.
+        $wpdb->query(
+            $wpdb->prepare(
+                "UPDATE {$wpdb->prefix}clusterlane_post_metrics
+                 SET primary_cluster = %s
+                 WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")
+                 AND (primary_cluster IS NULL OR primary_cluster = '')",
+                array_merge( [ $cluster_key ], $post_ids )
+            )
+        );
+
+        // --- Cluster-relative content classification ---
+        // Recompute each member's role (pillar / support / light / orphan)
+        // against THIS cluster's own inbound-link distribution, then
+        // replace the stored rows for this cluster wholesale so members
+        // that have left the cluster don't leave stale rows behind.
+        $this->classify_cluster_members( $cluster_key, (array) $metrics, $total );
+        self::sync_site_roles( array_map( 'intval', $post_ids ) );
+    }
+
+    /**
+     * The site-wide role of an article (Article Inspector without a cluster
+     * filter, Text Match, snapshots) = its best role in any of its clusters:
+     * pillar > support > light > orphan. So "Pillars" site-wide are the
+     * clusters' pillars, not every well-linked article (0.98.145). Articles
+     * in no cluster keep the scanner's guess, which is never "pillar".
+     * @param int[]|null $ids null = every article
+     */
+    public static function sync_site_roles( ?array $ids = null ): void {
+        global $wpdb;
+        $pm  = $wpdb->prefix . 'clusterlane_post_metrics';
+        $cpc = $wpdb->prefix . 'clusterlane_cluster_post_class';
+        if ( $ids === null ) {
+            $wpdb->query( $wpdb->prepare( "UPDATE %i m JOIN (
+                    SELECT post_id, MIN( FIELD( content_class, 'pillar', 'support', 'light', 'orphan' ) ) AS r FROM %i GROUP BY post_id
+                ) x ON x.post_id = m.post_id AND x.r > 0
+                SET m.content_class = ELT( x.r, 'pillar', 'support', 'light', 'orphan' )", $pm, $cpc ) );
+            // Not in any cluster (any more): never a pillar.
+            $wpdb->query( $wpdb->prepare( "UPDATE %i m LEFT JOIN %i c ON c.post_id = m.post_id SET m.content_class = 'support'
+                WHERE m.content_class = 'pillar' AND c.post_id IS NULL", $pm, $cpc ) );
+            return;
+        }
+        $ids = array_values( array_map( 'intval', $ids ) );
+        if ( ! $ids ) return;
+        $wpdb->query( $wpdb->prepare( "UPDATE %i m JOIN (
+                SELECT post_id, MIN( FIELD( content_class, 'pillar', 'support', 'light', 'orphan' ) ) AS r FROM %i
+                WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY post_id
+            ) x ON x.post_id = m.post_id AND x.r > 0
+            SET m.content_class = ELT( x.r, 'pillar', 'support', 'light', 'orphan' )", array_merge( [ $pm, $cpc ], $ids ) ) );
+        $wpdb->query( $wpdb->prepare( "UPDATE %i m LEFT JOIN %i c ON c.post_id = m.post_id SET m.content_class = 'support'
+            WHERE m.content_class = 'pillar' AND c.post_id IS NULL AND m.post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( [ $pm, $cpc ], $ids ) ) );
+    }
+
+    /**
+     * Classify every member of a cluster relative to that cluster's own
+     * inbound-link distribution, and persist the result.
+     *
+     * @param object[] $metrics Rows with ->post_id and ->inbound_links for
+     *                          this cluster's members (from compute_cluster()).
+     */
+    private function classify_cluster_members( string $cluster_key, array $metrics, int $total ): void {
+        global $wpdb;
+
+        $inbound_vals = array_map( fn( $m ) => (int) $m->inbound_links, $metrics );
+        $max_inbound  = $total > 0 ? max( $inbound_vals ) : 0;
+
+        $roles = [];
+        foreach ( $metrics as $m ) {
+            $roles[ (int) $m->post_id ] = $this->classify_relative( $inbound_vals, (int) $m->inbound_links, $max_inbound );
+        }
+
+        // Few pillars: keep the best-linked candidates (ties → higher overall
+        // score), demote the rest to support. A chosen pillar is the only one.
+        $chosen = self::chosen_pillar( $cluster_key );
+        if ( $chosen && ! isset( $roles[ $chosen ] ) ) $chosen = 0; // no longer a member
+        $by_id  = [];
+        foreach ( $metrics as $m ) $by_id[ (int) $m->post_id ] = $m;
+        $cands  = array_keys( array_filter( $roles, fn( $r ) => $r[0] === 'pillar' ) );
+        usort( $cands, fn( $a, $b ) => ( (int) $by_id[ $b ]->inbound_links <=> (int) $by_id[ $a ]->inbound_links )
+                                     ?: ( (int) $by_id[ $b ]->overall_score <=> (int) $by_id[ $a ]->overall_score ) );
+        // A page-cluster's own page is its pillar unless another was chosen (0.98.180).
+        $hub  = strpos( $cluster_key, HubClusters::PREFIX ) === 0 ? (int) substr( $cluster_key, strlen( HubClusters::PREFIX ) ) : 0;
+        if ( ! $chosen && $hub && isset( $roles[ $hub ] ) ) $chosen = $hub;
+        $keep = $chosen ? [ $chosen ] : array_slice( $cands, 0, $total >= self::SECOND_PILLAR_FROM ? 2 : 1 );
+        foreach ( $roles as $pid => &$r ) {
+            if ( in_array( $pid, $keep, true ) ) $r[0] = 'pillar';
+            elseif ( $r[0] === 'pillar' ) $r[0] = 'support';
+        }
+        unset( $r );
+
+        $values = []; // six per row, in column order
+        $now    = current_time( 'mysql' );
+        foreach ( $metrics as $m ) {
+            $post_id = (int) $m->post_id;
+            [ $class, $percentile ] = $roles[ $post_id ];
+            array_push( $values, $cluster_key, $post_id, $class, (int) $m->inbound_links, (int) $percentile, $now );
+        }
+        $rows = intdiv( count( $values ), 6 );
+
+        $table = $wpdb->prefix . 'clusterlane_cluster_post_class';
+
+        // Wholesale replace for this cluster_key: drop members that no
+        // longer belong, then insert the freshly computed set.
+        $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE cluster_key = %s', $table, $cluster_key ) );
+
+        if ( $rows > 0 ) {
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $wpdb->query( $wpdb->prepare(
+                'INSERT INTO %i (cluster_key, post_id, content_class, inbound_links, percentile, computed_at)
+                 VALUES ' . implode( ', ', array_fill( 0, $rows, '(%s, %d, %s, %d, %d, %s)' ) ),
+                array_merge( [ $table ], $values )
+            ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+        }
+    }
+
+    /**
+     * Classify a single post's structural role relative to its OWN
+     * cluster's inbound-link distribution, rather than against a fixed
+     * global threshold. A post with 5 inbound links is a pillar in a
+     * sparse 8-post cluster and unremarkable in a dense 40-post mesh —
+     * so the threshold has to move with the cluster.
+     *
+     * The reported percentile is a ceiling CDF: the share of cluster
+     * members whose inbound count is <= this post's. That number is only
+     * ever used for display; the *class* is decided with two guards on
+     * top of it, because a bare CDF misclassifies in two ways:
+     *
+     *   1. Flat clusters. If every member has the same inbound count, the
+     *      CDF is 100 for all of them, so a naive ">=75 -> pillar" rule
+     *      makes the entire cluster pillars. Members here are structurally
+     *      equivalent, so they are all 'support'.
+     *   2. Bottom ties. With a <= CDF, a large tie at a low value inflates
+     *      everyone's percentile: inbound [1,1,1,1,5] puts each 1-link post
+     *      at the 80th percentile. An absolute inbound floor (PILLAR_MIN_INBOUND)
+     *      stops those from becoming pillars.
+     *
+     * Bands, once the guards pass:
+     *   - 0 inbound links                              -> orphan (any cluster)
+     *   - all members tied                             -> support (any cluster)
+     *   - >= 75th percentile AND >= PILLAR_MIN_INBOUND -> pillar
+     *   - <  35th percentile AND <= LIGHT_MAX_INBOUND  -> light
+     *   - everything else                              -> support
+     *
+     * Small clusters (< MIN_CLUSTER_FOR_PERCENTILE) don't have enough
+     * points for a meaningful percentile split, so only the single
+     * best-linked post — if it clears the pillar floor — is a pillar, and
+     * everyone else is 'support'. 'light' is never emitted below that size:
+     * it means "peripheral in a rich structure", which is meaningless at
+     * n=2 or n=3.
+     *
+     * @return array{0: string, 1: int} [content_class, percentile 0-100]
+     */
+    private function classify_relative( array $inbound_vals, int $post_inbound, int $max_inbound ): array {
+        // 0 inbound links is an orphan in any cluster, of any size.
+        if ( $post_inbound === 0 ) return [ 'orphan', 0 ];
+
+        $total       = count( $inbound_vals );
+        $min_inbound = $total > 0 ? min( $inbound_vals ) : 0;
+
+        // Reported percentile (ceiling CDF), used for display in every branch below.
+        $rank       = count( array_filter( $inbound_vals, fn( $v ) => $v <= $post_inbound ) );
+        $percentile = $total > 0 ? (int) round( ( $rank / $total ) * 100 ) : 0;
+
+        // Flat cluster: everyone tied. No basis to single anyone out.
+        if ( $max_inbound === $min_inbound ) {
+            return [ 'support', $percentile ];
+        }
+
+        // Small cluster: percentile splits are noise. Only the top post is
+        // a pillar (and only if it clears the floor); no 'light' at this size.
+        if ( $total < self::MIN_CLUSTER_FOR_PERCENTILE ) {
+            $class = ( $post_inbound === $max_inbound && $post_inbound >= self::PILLAR_MIN_INBOUND )
+                ? 'pillar'
+                : 'support';
+            return [ $class, $percentile ];
+        }
+
+        // Normal cluster: percentile bands, gated on absolute inbound floors.
+        $class = match ( true ) {
+            $percentile >= 75 && $post_inbound >= self::PILLAR_MIN_INBOUND => 'pillar',
+            $percentile <  35 && $post_inbound <= self::LIGHT_MAX_INBOUND  => 'light',
+            default                                                        => 'support',
+        };
+
+        return [ $class, $percentile ];
+    }
+
+    /**
+     * Compute metrics for all active clusters.
+     * Returns number of clusters processed.
+     */
+    public function compute_all(): int {
+        global $wpdb;
+        // Links from articles scanned after an article weren't in its counts yet (0.98.179).
+        if ( class_exists( Scanner::class ) ) Scanner::refresh_counts();
+        $keys = $wpdb->get_col(
+            "SELECT cluster_key FROM {$wpdb->prefix}clusterlane_clusters WHERE is_active = 1"
+        );
+        foreach ( $keys as $key ) {
+            $this->compute_cluster( $key );
+        }
+        /** Fires after Metrics — History takes the day's snapshot here. */
+        do_action( 'clusterlane_metrics_computed', count( $keys ) );
+        return count( $keys );
+    }
+
+    /**
+     * The site's cluster health as the Dashboard ring shows it: the average
+     * weighted by articles, so a 43-article cluster counts for more than a
+     * 5-article one. One definition for the Dashboard, snapshots and reports.
+     * @param object[] $clusters rows from get_clusters()
+     */
+    public static function weighted_health( array $clusters ): int {
+        $sum = 0; $n = 0;
+        foreach ( $clusters as $c ) {
+            $sum += (int) $c->health_score * (int) $c->total_members;
+            $n   += (int) $c->total_members;
+        }
+        return $n > 0 ? (int) round( $sum / $n ) : 0;
+    }
+
+    /* ------------------------------------------------------------------
+       Read
+    ------------------------------------------------------------------ */
+
+    /** Get all active clusters ordered by health ascending. */
+    public function get_clusters(): array {
+        global $wpdb;
+        return $wpdb->get_results(
+            "SELECT * FROM {$wpdb->prefix}clusterlane_clusters
+             WHERE is_active = 1
+             ORDER BY health_score ASC"
+        ) ?: [];
+    }
+
+    /** Get cluster summary stats. Empty clusters (nothing scanned in them yet) don't count towards the average health. */
+    public function get_summary(): array {
+        global $wpdb;
+        $table = $wpdb->prefix . 'clusterlane_clusters';
+        $row   = $wpdb->get_row( $wpdb->prepare(
+            'SELECT COUNT(*) AS total,
+                    ROUND(AVG(CASE WHEN total_members > 0 THEN health_score END),1) AS avg_health,
+                    SUM(total_members) AS total_articles
+             FROM %i WHERE is_active = 1',
+            $table
+        ), ARRAY_A );
+        return [
+            'total_clusters'  => (int)   ( $row['total']         ?? 0 ),
+            'avg_health'      => (float) ( $row['avg_health']     ?? 0 ),
+            'total_articles'  => (int)   ( $row['total_articles'] ?? 0 ),
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       Private helpers
+    ------------------------------------------------------------------ */
+
+    /** Get post IDs belonging to a cluster — handles both taxonomy and custom clusters. */
+    /**
+     * Resolves a cluster's member post IDs. Public (and with the
+     * object-based wrapper below) so REST endpoints can share this
+     * instead of re-implementing the custom-vs-taxonomy branch — RestGraph,
+     * RestGap, and RestLinkSuggest all need exactly this resolution.
+     */
+    public function get_member_ids( int $term_id, string $cluster_type, string $cluster_key = '' ): array {
+        // A page and the pages pointing to it (0.98.180).
+        if ( HubClusters::is_hub_type( $cluster_type ) ) return HubClusters::members( $term_id );
+
+        // Custom cluster — fetch from clusterlane_custom_members.
+        if ( $cluster_type === 'custom' ) {
+            global $wpdb;
+            // Only members that are still in scope (published, not protected).
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $ids = $wpdb->get_col( $wpdb->prepare(
+                "SELECT cm.post_id FROM {$wpdb->prefix}clusterlane_custom_members cm
+                 INNER JOIN {$wpdb->posts} p ON p.ID = cm.post_id
+                 WHERE cm.cluster_key = %s AND " . Scope::sql( 'p' ), // phpcs:ignore
+                $cluster_key
+            ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter
+            return array_map( 'intval', $ids );
+        }
+
+        // Taxonomy cluster.
+        $posts = get_posts( Scope::query_args( [
+            'post_type'      => Scope::post_types(),
+            'post_status'    => 'publish',
+            'posts_per_page' => -1,
+            'fields'         => 'ids',
+            'tax_query'      => [[ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- members of one term, ids only
+                'taxonomy'         => $cluster_type,
+                'field'            => 'term_id',
+                'terms'            => $term_id,
+                'include_children' => false,
+            ]],
+            'no_found_rows'          => true,
+            'update_post_meta_cache' => false,
+            'update_post_term_cache' => false,
+        ] ) );
+        return array_map( 'intval', $posts );
+    }
+
+    /** Same as get_member_ids(), but takes a clusterlane_clusters row directly. */
+    public function get_member_post_ids( object $cluster ): array {
+        return $this->get_member_ids(
+            (int) ( $cluster->term_id ?? 0 ),
+            $cluster->cluster_type,
+            $cluster->cluster_key ?? ''
+        );
+    }
+
+    /** Gini coefficient (0-100) for an array of values. */
+    private function gini( array $values ): int {
+        $n = count( $values );
+        if ( $n < 2 ) return 0;
+
+        sort( $values );
+        $sum    = array_sum( $values );
+        if ( $sum === 0 ) return 0;
+
+        $cumsum = 0;
+        $index  = 0;
+        foreach ( $values as $v ) {
+            $index++;
+            $cumsum += $v;
+        }
+
+        // Standard Gini formula.
+        $weighted = 0;
+        $i        = 1;
+        foreach ( $values as $v ) {
+            $weighted += ( 2 * $i - $n - 1 ) * $v;
+            $i++;
+        }
+
+        return (int) round( abs( $weighted / ( $n * $sum ) ) * 100 );
+    }
+
+    /**
+     * Entropy score (0-100): measures how evenly posts link to each other.
+     * High entropy = many different link targets (broad cluster).
+     * Low entropy  = links concentrated on a few hubs.
+     */
+    private function entropy_score( array $post_ids ): int {
+        if ( count( $post_ids ) < 2 ) return 0;
+
+        global $wpdb;
+        $post_ids        = array_map( 'intval', $post_ids );
+
+        $outbound_counts = $wpdb->get_col(
+            $wpdb->prepare(
+                "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links
+                 WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")
+                 AND target_type = 'post'
+                 GROUP BY source_id",
+                array_merge( $post_ids, $post_ids )
+            )
+        );
+
+        if ( empty( $outbound_counts ) ) return 0;
+
+        $total = array_sum( $outbound_counts );
+        if ( $total === 0 ) return 0;
+
+        $entropy = 0.0;
+        foreach ( $outbound_counts as $c ) {
+            $p        = $c / $total;
+            $entropy -= $p * log( $p, 2 );
+        }
+
+        // Normalise to 0-100 (max entropy = log2(n)).
+        $max_entropy = log( count( $outbound_counts ), 2 );
+        if ( $max_entropy <= 0 ) return 0;
+
+        return (int) round( ( $entropy / $max_entropy ) * 100 );
+    }
+
+    /** Health score (0-100) weighted formula. */
+    private function health(
+        int   $avg_overall,
+        float $orphan_ratio,
+        int   $density,
+        int   $gini,
+        int   $entropy,
+        int   $total
+    ): int {
+        $orphan_score  = (int) round( ( 1 - $orphan_ratio ) * 100 );
+        $gini_score    = 100 - $gini;  // lower Gini = more equal = better
+
+        $health = (int) round(
+            $avg_overall   * 0.30 +
+            $orphan_score  * 0.25 +
+            $density       * 0.20 +
+            $gini_score    * 0.15 +
+            $entropy       * 0.10
+        );
+
+        return min( 100, max( 0, $health ) );
+    }
+
+    /** Classify cluster archetype based on structural metrics and user-configured thresholds. */
+    private function archetype(
+        int   $entropy,
+        int   $gini,
+        int   $density,
+        float $orphan_ratio,
+        int   $total,
+        int   $avg_overall
+    ): string {
+        // Load thresholds from settings.
+        $settings = \Clusterlane\Core\Settings::get_instance();
+        $t        = wp_parse_args(
+            $settings ? $settings->get( 'thresholds', [] ) : [],
+            [
+                'balanced_mesh_entropy'     => 80,
+                'balanced_mesh_gini'        => 40,
+                'authority_pyramid_entropy' => 60,
+                'authority_pyramid_gini'    => 55,
+                'developed_min_posts'       => 12,
+                'developed_min_score'       => 90,
+                'developed_max_orphan'      => 10,
+                'emerging_max_posts'        => 6,
+            ]
+        );
+
+        if ( $total < (int) $t['emerging_max_posts'] ) return 'Emerging Cluster';
+
+        if ( $entropy > (int) $t['balanced_mesh_entropy'] && $gini < (int) $t['balanced_mesh_gini'] )
+            return 'Balanced Mesh';
+
+        if ( $entropy > (int) $t['authority_pyramid_entropy'] && $gini > (int) $t['authority_pyramid_gini'] )
+            return 'Authority Pyramid';
+
+        if ( $entropy < 50 && $gini > 70 ) return 'Over-Centralized Hub';
+        if ( $density < 8 )                return 'Weak Structure';
+        if ( $orphan_ratio > 0.25 )        return 'Fragmented';
+
+        if (
+            $total      >= (int) $t['developed_min_posts']  &&
+            $avg_overall >= (int) $t['developed_min_score'] &&
+            $orphan_ratio <= ( (int) $t['developed_max_orphan'] / 100 ) &&
+            $density >= 8
+        ) {
+            return 'Developed Cluster';
+        }
+
+        // Analysed, but no single structural pattern dominates (0.98.109:
+        // was "Unclassified", which read as "not analysed yet"). An empty
+        // archetype still means "never computed".
+        return 'Mixed';
+    }
+}
+
Index: /clusterlane/tags/1.3.2/intelligence/core/class-cluster-sections.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-cluster-sections.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-cluster-sections.php	(revision 3751027)
@@ -0,0 +1,345 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * ClusterSections (0.98.160) — groups the cluster list into the site's own
+ * sections, the way visitors see them: the main menu.
+ *
+ * For each cluster, in this order:
+ *   1. The editor's own choice (option clusterlane_cluster_sections, set from the
+ *      cluster's detail panel).
+ *   2. A category cluster: the top-level menu item its category sits under in
+ *      the main menu (the category itself, or its nearest parent that is in
+ *      the menu). The menu wins over the category tree: on a site where
+ *      Churches is a child of Museums but has its own menu item, Churches is
+ *      its own section.
+ *   3. Not in the menu: its top-level parent category.
+ *   4. Tag and custom clusters (no category of their own): the section most of
+ *      their articles belong to, through the articles' categories — when at
+ *      least 40 % agree. Otherwise "Other".
+ *
+ * Main menu: a classic menu assigned to a theme location (primary / main /
+ * header first), else the classic menu with the most category items; on block
+ * themes the first Navigation (wp_navigation) with category links.
+ * Nothing here is stored except the editor's choices — sections follow the
+ * menu and the categories as they change.
+ */
+final class ClusterSections {
+
+    public const OPTION = 'clusterlane_cluster_sections';
+    /** The editor's own groups, in their order: [ ['key' => 'grp_…', 'label' => '…'], … ] (0.98.161). */
+    public const GROUPS = 'clusterlane_cluster_groups';
+    public const OTHER  = 'other';
+    private const MAJORITY = 0.4;
+
+    /**
+     * @param object[] $clusters rows of clusterlane_clusters (cluster_key, cluster_type, term_id)
+     * @param array<string,int[]> $members cluster_key → member post IDs
+     * @return array{sections: array<int,array{key:string,label:string,source:string}>, map: array<string,array{section:string,how:string}>, menu: string}
+     */
+    public static function compute( array $clusters, array $members ): array {
+        $menu      = self::menu();                  // term_id → [key,label,order]
+        $sections  = [];                            // key → [key,label,order,source]
+        $cat_cache = [];
+        // $tax: 'category', or 'product_cat' for WooCommerce product categories (0.98.172).
+        // Term IDs are unique across taxonomies, so one menu map serves both.
+        $resolve   = function ( int $term_id, string $tax = 'category' ) use ( &$menu, &$sections, &$cat_cache ): ?string {
+            if ( isset( $cat_cache[ $term_id ] ) ) return $cat_cache[ $term_id ];
+            $sec = null;
+            // Nearest ancestor-or-self that the menu lists.
+            $t = $term_id; $guard = 0;
+            while ( $t && $guard++ < 20 ) {
+                if ( isset( $menu['terms'][ $t ] ) ) { $sec = $menu['terms'][ $t ]; break; }
+                $term = get_term( $t, $tax );
+                $t = ( $term && ! is_wp_error( $term ) ) ? (int) $term->parent : 0;
+            }
+            if ( $sec ) {
+                $sections[ $sec['key'] ] ??= $sec + [ 'source' => 'menu' ];
+                return $cat_cache[ $term_id ] = $sec['key'];
+            }
+            // The top-level parent category.
+            $top = get_term( $term_id, $tax ); $guard = 0;
+            while ( $top && ! is_wp_error( $top ) && (int) $top->parent && $guard++ < 20 ) $top = get_term( (int) $top->parent, $tax );
+            if ( ! $top || is_wp_error( $top ) || $top->slug === 'uncategorized' ) return $cat_cache[ $term_id ] = null;
+            $key = 'cat_' . (int) $top->term_id;
+            $sections[ $key ] ??= [ 'key' => $key, 'label' => html_entity_decode( $top->name, ENT_QUOTES ), 'order' => 1000, 'source' => 'category' ];
+            return $cat_cache[ $term_id ] = $key;
+        };
+
+        $manual = (array) get_option( self::OPTION, [] );
+        $map    = [];
+        $later  = [];
+        // Pages as clusters (0.98.181): grouped by the page at the top of their
+        // hierarchy — an island with the clusters of its areas. A top page with
+        // only its own cluster joins the others of its kind ("Islands").
+        $tops = [];
+        foreach ( $clusters as $c ) {
+            if ( HubClusters::is_hub_type( (string) $c->cluster_type ) && (int) $c->term_id ) $tops[ $c->cluster_key ] = HubClusters::top_of( (int) $c->term_id );
+        }
+        $per_top = array_count_values( $tops );
+        // Other taxonomies (0.98.181): a top-level term whose cluster is alone
+        // there doesn't make a section of its own; its taxonomy does ("Types").
+        $tax_top = [];
+        foreach ( $clusters as $c ) {
+            $tx = (string) $c->cluster_type;
+            if ( in_array( $tx, ClusterTypes::BUILT_IN, true ) || isset( $tops[ $c->cluster_key ] ) || ! (int) $c->term_id || ! is_taxonomy_hierarchical( $tx ) ) continue;
+            $t = get_term( (int) $c->term_id, $tx ); $guard = 0;
+            while ( $t && ! is_wp_error( $t ) && (int) $t->parent && $guard++ < 20 ) $t = get_term( (int) $t->parent, $tx );
+            if ( $t && ! is_wp_error( $t ) ) $tax_top[ $c->cluster_key ] = $tx . ':' . (int) $t->term_id;
+        }
+        $per_tax_top = array_count_values( $tax_top );
+        foreach ( $clusters as $c ) {
+            $k = $c->cluster_key;
+            if ( isset( $tax_top[ $k ] ) && $per_tax_top[ $tax_top[ $k ] ] === 1 && ! isset( $menu['terms'][ (int) $c->term_id ] ) ) {
+                $tx  = (string) $c->cluster_type;
+                $obj = get_taxonomy( $tx );
+                $sk  = 'tax_' . $tx;
+                $sections[ $sk ] ??= [ 'key' => $sk, 'label' => $obj ? html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ) : $tx, 'order' => 1000, 'source' => 'category' ];
+                $map[ $k ] = [ 'section' => $sk, 'how' => 'category' ];
+                continue;
+            }
+            if ( isset( $tops[ $k ] ) ) {
+                $top = $tops[ $k ];
+                if ( $per_top[ $top ] > 1 ) {
+                    $sk = 'page_' . $top;
+                    $sections[ $sk ] ??= [ 'key' => $sk, 'label' => html_entity_decode( get_the_title( $top ), ENT_QUOTES, 'UTF-8' ), 'order' => 1000, 'source' => 'pages' ];
+                } else {
+                    $pt = get_post_type_object( (string) get_post_type( $top ) );
+                    $sk = 'pages_' . ( $pt ? $pt->name : 'x' );
+                    $sections[ $sk ] ??= [ 'key' => $sk, 'label' => $pt ? html_entity_decode( (string) $pt->labels->name, ENT_QUOTES, 'UTF-8' ) : __( 'Pages', 'clusterlane' ), 'order' => 1000, 'source' => 'pages' ];
+                }
+                $map[ $k ] = [ 'section' => $sk, 'how' => 'pages' ];
+                continue;
+            }
+            if ( ( in_array( $c->cluster_type, [ 'category', 'product_cat' ], true ) || ( $c->cluster_type !== 'custom' && is_taxonomy_hierarchical( (string) $c->cluster_type ) ) ) && (int) $c->term_id ) { // any hierarchical taxonomy resolves by its top term (0.98.177)
+                $s = $resolve( (int) $c->term_id, (string) $c->cluster_type );
+                if ( $s ) { $map[ $k ] = [ 'section' => $s, 'how' => $sections[ $s ]['source'] ]; continue; }
+            }
+            $later[] = $c;
+        }
+
+        // Tags and custom clusters: where their articles live.
+        if ( $later ) {
+            $ids = [];
+            foreach ( $later as $c ) foreach ( $members[ $c->cluster_key ] ?? [] as $id ) $ids[ (int) $id ] = true;
+            $post_cats = self::post_categories( array_keys( $ids ) );
+            foreach ( $later as $c ) {
+                $votes = [];
+                $n     = 0;
+                foreach ( $members[ $c->cluster_key ] ?? [] as $id ) {
+                    $secs = [];
+                    foreach ( $post_cats[ (int) $id ] ?? [] as $tid ) { $s = $resolve( $tid ); if ( $s ) $secs[ $s ] = true; }
+                    if ( ! $secs ) continue;
+                    $n++;
+                    foreach ( array_keys( $secs ) as $s ) $votes[ $s ] = ( $votes[ $s ] ?? 0 ) + 1;
+                }
+                arsort( $votes );
+                $best = (string) array_key_first( $votes );
+                // Share of ALL the cluster's articles, not only those with a category:
+                // 2 blog posts among 40 pages without categories made a "Blog" cluster (0.98.181).
+                $all_n = count( $members[ $c->cluster_key ] ?? [] );
+                $map[ $c->cluster_key ] = ( $best !== '' && $votes[ $best ] >= max( 1, self::MAJORITY * max( $n, $all_n ) ) )
+                    ? [ 'section' => $best, 'how' => 'articles' ]
+                    : [ 'section' => self::OTHER, 'how' => 'none' ];
+            }
+        }
+
+        // The editor's choices last — they win. A choice of a section that no
+        // longer exists (menu item removed) is ignored.
+        $all = self::choices( $menu );
+        foreach ( $manual as $k => $s ) {
+            if ( ! isset( $map[ $k ] ) ) continue;
+            if ( $s === self::OTHER || isset( $all[ $s ] ) ) {
+                if ( isset( $all[ $s ] ) ) $sections[ $s ] ??= $all[ $s ];
+                $map[ $k ] = [ 'section' => (string) $s, 'how' => 'manual' ];
+            }
+        }
+        // The editor's own groups are always listed — even empty, so clusters can be moved in.
+        foreach ( $all as $k => $sec ) if ( $sec['source'] === 'group' ) $sections[ $k ] ??= $sec;
+        if ( in_array( self::OTHER, array_column( $map, 'section' ), true ) ) {
+            $sections[ self::OTHER ] = [ 'key' => self::OTHER, 'label' => __( 'Other', 'clusterlane' ), 'order' => 99999, 'source' => 'none' ];
+        }
+        // Menu order first, then category sections A–Z, Other last. Only sections in use.
+        $used     = array_flip( array_column( $map, 'section' ) );
+        $sections = array_filter( $sections, fn( $s ) => isset( $used[ $s['key'] ] ) || $s['source'] === 'group' );
+        uasort( $sections, fn( $a, $b ) => ( $a['order'] <=> $b['order'] ) ?: strcasecmp( $a['label'], $b['label'] ) );
+
+        return [
+            'sections' => array_values( array_map( fn( $s ) => [ 'key' => $s['key'], 'label' => $s['label'], 'source' => $s['source'] ], $sections ) ),
+            'choices'  => array_values( array_map( fn( $s ) => [ 'key' => $s['key'], 'label' => $s['label'], 'source' => $s['source'] ], $all ) ),
+            'map'      => $map,
+            'menu'     => $menu['name'],
+        ];
+    }
+
+    /** Every section an editor can pick: their own groups, the menu's top items, then top-level categories. */
+    private static function choices( array $menu ): array {
+        $out = [];
+        foreach ( self::groups() as $i => $g ) $out[ $g['key'] ] = $g + [ 'order' => -1000 + $i, 'source' => 'group' ];
+        foreach ( $menu['tops'] as $s ) $out[ $s['key'] ] = $s + [ 'source' => 'menu' ];
+        $cats = get_terms( [ 'taxonomy' => 'category', 'parent' => 0, 'hide_empty' => false ] );
+        if ( ! is_wp_error( $cats ) ) {
+            foreach ( $cats as $t ) {
+                if ( $t->slug === 'uncategorized' ) continue;
+                if ( isset( $menu['terms'][ (int) $t->term_id ] ) ) continue; // already a menu section
+                $out[ 'cat_' . $t->term_id ] = [ 'key' => 'cat_' . $t->term_id, 'label' => html_entity_decode( $t->name, ENT_QUOTES ), 'order' => 1000, 'source' => 'category' ];
+            }
+        }
+        return $out;
+    }
+
+    /** @return array<int,array{key:string,label:string}> */
+    public static function groups(): array {
+        $out = [];
+        foreach ( (array) get_option( self::GROUPS, [] ) as $g ) {
+            if ( is_array( $g ) && ! empty( $g['key'] ) && isset( $g['label'] ) ) $out[] = [ 'key' => (string) $g['key'], 'label' => (string) $g['label'] ];
+        }
+        return $out;
+    }
+
+    /**
+     * Change the editor's own groups: add (label) → the new key, rename (key, label),
+     * delete (key; its clusters go back to Automatic), move (key, dir -1 | 1).
+     */
+    public static function edit_group( string $action, string $key = '', string $label = '', int $dir = 0 ): ?string {
+        $groups = self::groups();
+        $label  = trim( mb_substr( sanitize_text_field( $label ), 0, 60 ) );
+        $at     = array_search( $key, array_column( $groups, 'key' ), true );
+        switch ( $action ) {
+            case 'add':
+                if ( $label === '' ) return null;
+                $key = 'grp_' . substr( md5( uniqid( '', true ) ), 0, 8 );
+                $groups[] = [ 'key' => $key, 'label' => $label ];
+                break;
+            case 'rename':
+                if ( $at === false || $label === '' ) return null;
+                $groups[ $at ]['label'] = $label;
+                break;
+            case 'delete':
+                if ( $at === false ) return null;
+                array_splice( $groups, $at, 1 );
+                $manual = (array) get_option( self::OPTION, [] );
+                update_option( self::OPTION, array_filter( $manual, fn( $s ) => $s !== $key ), false );
+                break;
+            case 'move':
+                $to = $at === false ? false : $at + ( $dir < 0 ? -1 : 1 );
+                if ( $to === false || $to < 0 || $to >= count( $groups ) ) return null;
+                [ $groups[ $at ], $groups[ $to ] ] = [ $groups[ $to ], $groups[ $at ] ];
+                break;
+            default:
+                return null;
+        }
+        update_option( self::GROUPS, array_values( $groups ), false );
+        return $key;
+    }
+
+    /** Save (or clear with '') the editor's section for one cluster. */
+    public static function set( string $cluster_key, string $section ): void {
+        $all = (array) get_option( self::OPTION, [] );
+        if ( $section === '' ) unset( $all[ $cluster_key ] );
+        else $all[ $cluster_key ] = sanitize_key( $section );
+        update_option( self::OPTION, $all, false );
+    }
+
+    /**
+     * The main menu, reduced to what sections need.
+     * @return array{name:string, tops: array<int,array{key:string,label:string,order:int}>, terms: array<int,array{key:string,label:string,order:int}>}
+     */
+    public static function menu(): array {
+        $empty = [ 'name' => '', 'tops' => [], 'terms' => [] ];
+        $items = null; $name = '';
+
+        // Classic menus: a menu in a theme location — primary/main/header first.
+        if ( function_exists( 'wp_get_nav_menu_items' ) ) {
+            $locs  = (array) get_nav_menu_locations();
+            $ranked = [];
+            foreach ( $locs as $loc => $menu_id ) {
+                if ( ! $menu_id ) continue;
+                $ranked[] = [ preg_match( '/primary|main|header|top/i', (string) $loc ) ? 0 : 1, (int) $menu_id ];
+            }
+            sort( $ranked );
+            $candidates = array_column( $ranked, 1 );
+            if ( ! $candidates ) { // no locations: the menu with the most category items
+                $best = 0; $bestn = 0;
+                foreach ( (array) wp_get_nav_menus() as $m ) {
+                    $n = count( array_filter( (array) wp_get_nav_menu_items( $m->term_id ), fn( $i ) => in_array( $i->object, [ 'category', 'product_cat' ], true ) ) );
+                    if ( $n > $bestn ) { $best = (int) $m->term_id; $bestn = $n; }
+                }
+                if ( $best ) $candidates = [ $best ];
+            }
+            foreach ( $candidates as $menu_id ) {
+                $list = (array) wp_get_nav_menu_items( $menu_id );
+                if ( ! array_filter( $list, fn( $i ) => in_array( $i->object, [ 'category', 'product_cat' ], true ) ) ) continue;
+                $obj  = wp_get_nav_menu_object( $menu_id );
+                $name = $obj ? $obj->name : '';
+                $items = [];
+                foreach ( $list as $i ) {
+                    $items[] = [ 'id' => (int) $i->ID, 'parent' => (int) $i->menu_item_parent, 'label' => (string) $i->title, 'order' => (int) $i->menu_order,
+                                 'term' => in_array( $i->object, [ 'category', 'product_cat' ], true ) ? (int) $i->object_id : 0 ];
+                }
+                break;
+            }
+        }
+        // Block themes: the first Navigation with category links.
+        if ( $items === null && post_type_exists( 'wp_navigation' ) ) {
+            $navs = get_posts( [ 'post_type' => 'wp_navigation', 'post_status' => 'publish', 'numberposts' => 5, 'orderby' => 'date', 'order' => 'ASC' ] );
+            foreach ( $navs as $nav ) {
+                $flat = []; $order = 0;
+                $walk = function ( array $blocks, int $parent ) use ( &$walk, &$flat, &$order ) {
+                    foreach ( $blocks as $b ) {
+                        if ( ! in_array( $b['blockName'] ?? '', [ 'core/navigation-link', 'core/navigation-submenu' ], true ) ) continue;
+                        $a  = $b['attrs'] ?? [];
+                        $id = ++$order;
+                        $flat[] = [ 'id' => $id, 'parent' => $parent, 'label' => wp_strip_all_tags( (string) ( $a['label'] ?? '' ) ), 'order' => $id,
+                                    'term' => ( ( $a['type'] ?? '' ) === 'category' && ! empty( $a['id'] ) ) ? (int) $a['id'] : 0 ];
+                        if ( ! empty( $b['innerBlocks'] ) ) $walk( $b['innerBlocks'], $id );
+                    }
+                };
+                $walk( parse_blocks( (string) $nav->post_content ), 0 );
+                if ( array_filter( $flat, fn( $i ) => $i['term'] ) ) { $items = $flat; $name = $nav->post_title; break; }
+            }
+        }
+        if ( ! $items ) return $empty;
+
+        $by = [];
+        foreach ( $items as $i ) $by[ $i['id'] ] = $i;
+        $tops = []; $terms = [];
+        foreach ( $items as $i ) {
+            // Walk up to the top-level item.
+            $top = $i; $guard = 0;
+            while ( $top['parent'] && isset( $by[ $top['parent'] ] ) && $guard++ < 20 ) $top = $by[ $top['parent'] ];
+            $label = trim( html_entity_decode( wp_strip_all_tags( $top['label'] ), ENT_QUOTES ) );
+            if ( $label === '' && $top['term'] ) { $t = get_term( $top['term'], 'category' ); $label = $t && ! is_wp_error( $t ) ? $t->name : ''; }
+            $sec = [ 'key' => 'menu_' . $top['id'], 'label' => $label, 'order' => $top['order'] ];
+            if ( ! $i['parent'] ) $tops[ $sec['key'] ] = $sec;
+            if ( $i['term'] && ! isset( $terms[ $i['term'] ] ) ) $terms[ $i['term'] ] = $sec;
+        }
+        // Only top items that lead to a category can hold clusters.
+        $with_terms = array_flip( array_column( $terms, 'key' ) );
+        $tops = array_values( array_filter( $tops, fn( $s ) => isset( $with_terms[ $s['key'] ] ) ) );
+        return [ 'name' => $name, 'tops' => $tops, 'terms' => $terms ];
+    }
+
+    /** @return array<int,int[]> post ID → category term IDs */
+    private static function post_categories( array $ids ): array {
+        global $wpdb;
+        $out = [];
+        foreach ( array_chunk( array_map( 'intval', $ids ), 500 ) as $chunk ) {
+            if ( ! $chunk ) continue;
+            $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+                "SELECT tr.object_id, tt.term_id FROM {$wpdb->term_relationships} tr JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id WHERE tt.taxonomy = 'category' AND tr.object_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')',
+                $chunk
+            ) );
+            foreach ( (array) $rows as $r ) $out[ (int) $r->object_id ][] = (int) $r->term_id;
+        }
+        return $out;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-cluster-types.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-cluster-types.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-cluster-types.php	(revision 3751027)
@@ -0,0 +1,113 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * ClusterTypes (0.98.177)
+ *
+ * Clusters come from categories, tags, WooCommerce product categories, custom
+ * clusters — and now from any other public taxonomy of the scanned content
+ * (attraction categories, regions, genres …). cluster_type holds the taxonomy
+ * name; this class names and describes the types for the screens, and finds
+ * the clusters an article belongs to, whatever their taxonomy.
+ */
+final class ClusterTypes {
+
+    /** Taxonomies that are never offered as clusters. */
+    private const NEVER = [ 'post_format', 'nav_menu', 'link_category', 'product_visibility', 'product_type', 'product_shipping_class', 'wp_theme', 'wp_template_part_area', 'wp_pattern_category' ];
+
+    /** Types with their own settings and cards (everything else goes through enabled_terms). */
+    public const BUILT_IN = [ 'category', 'post_tag', 'product_cat', 'custom' ];
+
+    /**
+     * Public taxonomies of these post types that could become clusters, other
+     * than categories, tags and product categories.
+     *
+     * @param  string[] $post_types
+     * @return array<string, \WP_Taxonomy>
+     */
+    public static function extra_taxonomies( array $post_types ): array {
+        $out = [];
+        foreach ( get_object_taxonomies( array_values( $post_types ), 'objects' ) as $name => $tax ) {
+            if ( in_array( $name, self::BUILT_IN, true ) || in_array( $name, self::NEVER, true ) ) continue;
+            if ( empty( $tax->public ) || empty( $tax->show_ui ) || strlen( $name ) > 32 ) continue;
+            $out[ $name ] = $tax;
+        }
+        uasort( $out, fn( $a, $b ) => ( (int) ! $a->hierarchical <=> (int) ! $b->hierarchical ) ?: strcasecmp( $a->label, $b->label ) );
+        return $out;
+    }
+
+    /**
+     * Label, glyph and member noun per cluster type, for the scripts
+     * (window.clusterlaneClusterTypes). Types not listed fall back to the taxonomy name.
+     *
+     * @return array<string, array{label: string, plural: string, glyph: string, noun: string}>
+     */
+    public static function for_js(): array {
+        $out = [
+            'category'    => [ 'label' => __( 'Category', 'clusterlane' ),         'plural' => __( 'Categories', 'clusterlane' ),         'glyph' => 'folder', 'noun' => 'article' ],
+            'post_tag'    => [ 'label' => __( 'Tag', 'clusterlane' ),              'plural' => __( 'Tags', 'clusterlane' ),               'glyph' => 'tag',    'noun' => 'article' ],
+            'custom'      => [ 'label' => __( 'Custom', 'clusterlane' ),           'plural' => __( 'Custom clusters', 'clusterlane' ),    'glyph' => 'pencil', 'noun' => 'article' ],
+            'product_cat' => [ 'label' => __( 'Product category', 'clusterlane' ), 'plural' => __( 'Product categories', 'clusterlane' ), 'glyph' => 'bag',    'noun' => 'product' ],
+        ];
+        foreach ( self::in_use() as $tax ) {
+            if ( isset( $out[ $tax ] ) ) continue;
+            if ( HubClusters::is_hub_type( $tax ) ) { // pages as clusters (0.98.180): named after the post type
+                $pt = get_post_type_object( HubClusters::post_type_of( $tax ) );
+                $out[ $tax ] = [
+                    'label'  => $pt ? html_entity_decode( (string) $pt->labels->singular_name, ENT_QUOTES, 'UTF-8' ) : $tax,
+                    'plural' => $pt ? html_entity_decode( (string) $pt->labels->name, ENT_QUOTES, 'UTF-8' ) : $tax,
+                    'glyph'  => 'link',
+                    'noun'   => 'article',
+                ];
+                continue;
+            }
+            $obj = get_taxonomy( $tax );
+            $out[ $tax ] = [
+                'label'  => $obj ? html_entity_decode( (string) $obj->labels->singular_name, ENT_QUOTES, 'UTF-8' ) : $tax,
+                'plural' => $obj ? html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ) : $tax,
+                'glyph'  => ( $obj && $obj->hierarchical ) ? 'folder' : 'tag',
+                'noun'   => 'article',
+            ];
+        }
+        return $out;
+    }
+
+    /** Taxonomies that have active clusters now (not custom). @return string[] */
+    public static function in_use(): array {
+        global $wpdb;
+        static $cache = null;
+        if ( $cache !== null ) return $cache;
+        $table = $wpdb->prefix . 'clusterlane_clusters';
+        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) return $cache = []; // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+        $cache = array_map( 'strval', (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT cluster_type FROM %i WHERE is_active = 1 AND cluster_type <> 'custom'", $table ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+        return $cache;
+    }
+
+    /**
+     * The cluster keys an article belongs to: its terms in every taxonomy that
+     * has clusters, plus custom clusters. Categories, tags, product categories
+     * and other taxonomies alike (before 0.98.177 only categories and tags).
+     *
+     * @return string[]
+     */
+    public static function post_keys( int $post_id ): array {
+        global $wpdb;
+        $keys = [];
+        $taxes = array_values( array_filter( array_unique( array_merge( [ 'category', 'post_tag' ], self::in_use() ) ), 'taxonomy_exists' ) );
+        if ( $taxes ) {
+            $terms = wp_get_object_terms( $post_id, $taxes, [ 'fields' => 'all' ] );
+            if ( ! is_wp_error( $terms ) ) {
+                foreach ( $terms as $t ) $keys[] = $t->taxonomy . '_' . (int) $t->term_id;
+            }
+        }
+        $custom = $wpdb->get_col( $wpdb->prepare( "SELECT cluster_key FROM {$wpdb->prefix}clusterlane_custom_members WHERE post_id = %d", $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+        return array_values( array_unique( array_merge( $keys, array_map( 'strval', (array) $custom ), HubClusters::keys_for( $post_id ) ) ) );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-compat.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-compat.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-compat.php	(revision 3751027)
@@ -0,0 +1,71 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Compat
+ *
+ * Detects articles whose visible content is rendered by a page builder
+ * from its own data store rather than from post_content. Writing a link
+ * into post_content on such an article "succeeds" but never appears on
+ * the site (and is overwritten on the next builder save), so Clusterlane
+ * refuses to write there and says why, instead of failing silently.
+ *
+ * Only counts while the builder is active: with the builder deactivated,
+ * WordPress renders post_content again and inserting works normally.
+ *
+ * Reading (scan, link graph) is unaffected — builders keep a rendered
+ * copy of the content in post_content.
+ */
+final class Compat {
+
+    /** Name of the page builder that renders this post, or null. */
+    public static function page_builder( int $post_id ): ?string {
+        $builders = [
+            // label        active when…                          post uses it when…
+            // Elementor is written natively by ElementorWriter; only refused
+            // if that is switched off via the clusterlane_elementor_write filter.
+            'Elementor'      => [ defined( 'ELEMENTOR_VERSION' ) && ( ! Edition::has( 'elementor' ) || ! apply_filters( 'clusterlane_elementor_write', true ) ),
+                                  fn() => get_post_meta( $post_id, '_elementor_edit_mode', true ) === 'builder' ],
+            'Beaver Builder' => [ defined( 'FL_BUILDER_VERSION' ), fn() => (bool) get_post_meta( $post_id, '_fl_builder_enabled', true ) ],
+            'Bricks'         => [ defined( 'BRICKS_VERSION' ),     fn() => get_post_meta( $post_id, '_bricks_editor_mode', true ) === 'bricks' ],
+        ];
+        foreach ( $builders as $name => [ $active, $uses ] ) {
+            if ( $active && $uses() ) return $name;
+        }
+        return apply_filters( 'clusterlane_page_builder', null, $post_id );
+    }
+
+    /**
+     * Display name of another user who has this article open in the editor
+     * right now (WordPress post lock), or null. Writing while someone edits
+     * would be overwritten by their next save — or overwrite their work.
+     */
+    public static function locked_by( int $post_id ): ?string {
+        if ( ! function_exists( 'wp_check_post_lock' ) ) {
+            require_once ABSPATH . 'wp-admin/includes/post.php';
+        }
+        $user_id = wp_check_post_lock( $post_id );
+        if ( ! $user_id ) return null;
+        $user = get_userdata( $user_id );
+        return $user ? $user->display_name : __( 'Another user', 'clusterlane' );
+    }
+
+    public static function locked_message( string $name ): string {
+        /* translators: %s: user display name */
+        return sprintf( __( '%s is editing this article right now. Try again when they are done, so neither change overwrites the other.', 'clusterlane' ), $name );
+    }
+
+    public static function page_builder_message( string $builder ): string {
+        if ( $builder === 'Elementor' && ! Edition::has( 'elementor' ) ) {
+            /* translators: %s: edition name */
+            return sprintf( __( 'This article is built with Elementor. Adding links to Elementor layouts is part of %s — add the link in the Elementor editor instead.', 'clusterlane' ), Edition::label( Edition::tier_for( 'elementor' ) ) );
+        }
+        return sprintf(
+            /* translators: %s: page builder name, e.g. Elementor */
+            __( 'This article is built with %1$s. Clusterlane can’t insert links into %2$s layouts yet — a link added here would not show on the site. Add it in the %3$s editor instead.', 'clusterlane' ),
+            $builder, $builder, $builder
+        );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-edition.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-edition.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-edition.php	(revision 3751027)
@@ -0,0 +1,123 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Edition
+ *
+ * The one place that decides which Clusterlane features are available.
+ * Every Pro/Agency feature asks Edition::has( '<feature>' ) — never the
+ * tier directly — so the free/premium split can be changed here alone.
+ *
+ * Tiers: free < pro < agency.
+ *
+ * No premium folder (free build) → always 'free'. In the Pro build the
+ * license decides (1.2.0): bootstrap.php hooks the clusterlane_edition filter and
+ * maps the Freemius plan to 'pro' or 'agency'; without an active license
+ * the tier stays at DEFAULT_TIER ('free').
+ *
+ * The CLUSTERLANE_EDITION constant in wp-config.php ('free' | 'pro' | 'agency') can
+ * only LOWER the tier — handy for trying the free experience on a licensed
+ * site. It never unlocks anything.
+ */
+final class Edition {
+
+    const DEFAULT_TIER = 'free';
+
+    const TIERS = [ 'free' => 0, 'pro' => 1, 'agency' => 2 ];
+
+    /**
+     * feature => lowest tier that has it. Anything not listed is free.
+     * Free (not listed): scan, clusters, health/archetypes, dashboard,
+     * Cluster/Article Inspector, graphs, Text Match, custom clusters,
+     * roles & capabilities, Help, support report, multisite install/cleanup.
+     */
+    const FEATURES = [
+        'ai'             => 'pro',    // every AI call: gap, briefs/drafts, link suggestions, analyses, cluster suggestions
+        'link_templates' => 'pro',    // Link Card Templates
+        'elementor'      => 'pro',    // writing links into Elementor layouts
+        'ai_cost'        => 'pro',    // AI usage & limits (only meaningful with AI)
+        'report'         => 'pro',
+        'pillar_box'     => 'pro',    // link box to the chosen pillar, added when the article is shown    // Report page for your own site (history, work done); Free shows the demo example
+        'network'        => 'agency', // Network Admin → Clusterlane overview + CSV
+        'network_ai'     => 'agency', // network-wide AI key, policy and per-site allowance
+    ];
+
+    public static function tier(): string {
+        // Free build (no pro__premium_only/ folder): always Free, whatever the
+        // constant or filter says — there is no premium code to run.
+        if ( ! self::premium_code() ) return 'free';
+
+        $tier = (string) apply_filters( 'clusterlane_edition', self::DEFAULT_TIER );
+        if ( ! isset( self::TIERS[ $tier ] ) ) $tier = 'free';
+
+        $cap = defined( 'CLUSTERLANE_EDITION' ) ? (string) CLUSTERLANE_EDITION : '';
+        if ( isset( self::TIERS[ $cap ] ) && self::TIERS[ $cap ] < self::TIERS[ $tier ] ) $tier = $cap;
+
+        return $tier;
+    }
+
+    /**
+     * The tier a Freemius license gives: 'agency' for the Agency plan, 'pro'
+     * for Pro (or a trial of it), 'free' without an active license.
+     *
+     * @param object|null $fs The Freemius instance (anything with can_use_premium_code() and is_plan()).
+     */
+    public static function from_license( $fs ): string {
+        if ( ! is_object( $fs ) || ! method_exists( $fs, 'can_use_premium_code' ) || ! $fs->can_use_premium_code() ) return 'free';
+        return $fs->is_plan( 'agency' ) ? 'agency' : 'pro';
+    }
+
+    /** True when the premium folder is installed (it defines CLUSTERLANE_PRO_LOADED). */
+    public static function premium_code(): bool {
+        return defined( 'CLUSTERLANE_PRO_LOADED' );
+    }
+
+    public static function has( string $feature ): bool {
+        $needs = self::FEATURES[ $feature ] ?? 'free';
+        return self::TIERS[ self::tier() ] >= self::TIERS[ $needs ];
+    }
+
+    /** Lowest tier that includes a feature ('free' | 'pro' | 'agency'). */
+    public static function tier_for( string $feature ): string {
+        return self::FEATURES[ $feature ] ?? 'free';
+    }
+
+    public static function label( ?string $tier = null ): string {
+        $n = Brand::name();
+        return [ 'free' => $n . ' Free', 'pro' => $n . ' Pro', 'agency' => $n . ' Agency' ][ $tier ?? self::tier() ];
+    }
+
+    public static function upgrade_url(): string {
+        return Brand::url( 'pricing' ); // keeps the clusterlane_upgrade_url filter
+    }
+
+    /** "… is part of Clusterlane Pro." — shown wherever a feature is missing. */
+    public static function missing_message( string $feature ): string {
+        /* translators: %s: edition name, e.g. Clusterlane Pro */
+        return sprintf( __( 'This feature is part of %s.', 'clusterlane' ), self::label( self::tier_for( $feature ) ) );
+    }
+
+    /**
+     * For REST permission callbacks: true, or a 403 explaining the edition.
+     * Use as: fn() => Capabilities::can_use() ? Edition::rest( 'ai' ) : false
+     */
+    public static function rest( string $feature ): bool|\WP_Error {
+        return self::has( $feature )
+            ? true
+            : new \WP_Error( 'clusterlane_edition', self::missing_message( $feature ), [ 'status' => 403 ] );
+    }
+
+    /** Data for JS (clusterlaneEdition). */
+    public static function for_js(): array {
+        $features = [];
+        foreach ( array_keys( self::FEATURES ) as $f ) $features[ $f ] = self::has( $f );
+        return [
+            'tier'       => self::tier(),
+            'label'      => self::label(),
+            'features'   => $features,
+            'upgradeUrl' => self::upgrade_url(),
+        ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-history.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-history.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-history.php	(revision 3751027)
@@ -0,0 +1,278 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * History — one snapshot of the site's figures per day (0.98.124).
+ *
+ * The figures are the ones the Dashboard shows, computed by the same code
+ * (Scanner::get_summary(), ClusterEngine::get_summary() and
+ * ClusterEngine::weighted_health()), so a report never disagrees with the
+ * Dashboard. History can't be recreated afterwards, so it is collected in
+ * every edition from the day this version is installed.
+ *
+ * When: after every Metrics run, once a day by WP-Cron, and — because
+ * WP-Cron only runs when the site has visitors — on an admin visit when the
+ * last snapshot is more than a day old. One row per local date; a later
+ * capture the same day replaces it.
+ *
+ * A snapshot records what is in the database: cluster health changes when
+ * Metrics runs, orphans and links also when a link is applied or undone.
+ */
+class History {
+
+    /**
+     * Bump when the health, role or archetype formulas change, so a chart
+     * can mark "the calculation changed here" instead of showing a jump
+     * the site never made.
+     *   1 — 0.98.124 (pillar cap 1–2, archetype Mixed, weighted health)
+     */
+    const METRICS_VERSION = 2; // 2 (0.98.145): site-wide roles come from the clusters — far fewer "pillars"
+
+    const CRON_HOOK   = 'clusterlane_daily_snapshot';
+    const LAST_OPTION = 'clusterlane_last_snapshot';
+    const LOCK        = 'clusterlane_snapshot_lock';
+
+    /** Daily rows are kept this long; older ones thin out to one a week (Mondays). */
+    const KEEP_DAILY_DAYS = 400;
+
+    private static ?Plugin $plugin = null;
+
+    public static function register_hooks( Plugin $plugin ): void {
+        self::$plugin = $plugin;
+        add_action( self::CRON_HOOK, [ self::class, 'on_cron' ] );
+        add_action( 'clusterlane_metrics_computed', [ self::class, 'on_metrics' ] );
+        add_action( 'admin_init', [ self::class, 'maybe_capture_on_visit' ] );
+        add_action( 'init', [ self::class, 'schedule' ] );
+    }
+
+    /** Schedule the daily event (03:17 site time) if it isn't. */
+    public static function schedule(): void {
+        if ( wp_installing() || wp_next_scheduled( self::CRON_HOOK ) ) return;
+        $first = ( new \DateTimeImmutable( 'tomorrow 03:17', wp_timezone() ) )->getTimestamp();
+        wp_schedule_event( $first, 'daily', self::CRON_HOOK );
+    }
+
+    public static function on_cron(): void {
+        self::capture( 'cron' );
+        self::prune();
+    }
+
+    public static function on_metrics(): void {
+        self::capture( 'metrics' );
+    }
+
+    /** Fallback for sites where WP-Cron rarely runs. */
+    public static function maybe_capture_on_visit(): void {
+        if ( wp_doing_ajax() || ! Capabilities::can_use() ) return;
+        if ( time() - (int) get_option( self::LAST_OPTION, 0 ) < DAY_IN_SECONDS ) return;
+        self::capture( 'admin' );
+    }
+
+    /**
+     * Take today's snapshot. Returns false when there is nothing to record
+     * yet (nothing scanned), when another capture is running, or on a DB error.
+     */
+    public static function capture( string $source = 'manual' ): bool {
+        if ( get_transient( self::LOCK ) ) return false;
+        set_transient( self::LOCK, 1, MINUTE_IN_SECONDS );
+        try {
+            $snap = self::collect();
+            if ( ! $snap || $snap['site']['scanned'] === 0 ) return false;
+
+            global $wpdb;
+            $site = $snap['site'];
+            $ok = $wpdb->replace(
+                $wpdb->prefix . 'clusterlane_snapshots',
+                [
+                    'snap_date'       => current_time( 'Y-m-d' ),
+                    'taken_at'        => current_time( 'mysql', true ),
+                    'source'          => substr( sanitize_key( $source ), 0, 10 ),
+                    'metrics_version' => self::METRICS_VERSION,
+                    'articles'        => $site['articles'],
+                    'scanned'         => $site['scanned'],
+                    'clusters'        => $site['clusters'],
+                    'health'          => $site['health'],
+                    'orphans'         => $site['orphans'],
+                    'avg_inbound'     => $site['avg_inbound'],
+                    'links'           => $site['links'],
+                    'data'            => (string) wp_json_encode( $snap ),
+                ],
+                [ '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%f', '%d', '%s' ]
+            );
+            if ( $ok === false ) return false;
+            update_option( self::LAST_OPTION, time(), false );
+            return true;
+        } finally {
+            delete_transient( self::LOCK );
+        }
+    }
+
+    /**
+     * The figures of the site right now.
+     * @return array{v:int, site:array<string,mixed>, clusters:array<int,array<string,mixed>>}|null
+     */
+    public static function collect(): ?array {
+        $plugin  = self::$plugin;
+        $scanner = $plugin ? $plugin->get( 'scanner' ) : null;
+        $engine  = $plugin ? $plugin->get( 'cluster_engine' ) : null;
+        if ( ! $scanner || ! $engine ) return null;
+
+        global $wpdb;
+        $m   = $wpdb->prefix . 'clusterlane_post_metrics';
+        $cpc = $wpdb->prefix . 'clusterlane_cluster_post_class';
+
+        $summary  = $scanner->get_summary();
+        $clusters = $engine->get_clusters();
+        $csum     = $engine->get_summary();
+
+        // Roles and meta descriptions over the scanned articles in scope — the
+        // same FROM/WHERE as the Article Inspector list (site scope).
+        $seo   = SeoMeta::sql_description( 'p', 'm.post_id' );
+        // $seo['join'] is prepared in SeoMeta; Scope::type_sql() is prepared; Scope::sql() has no values.
+        $roles = [ 'pillar' => 0, 'support' => 0, 'light' => 0, 'orphan' => 0 ];
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare(
+            'SELECT m.content_class AS role, COUNT(*) AS n FROM %i m INNER JOIN %i p ON p.ID = m.post_id ' . $seo['join']
+            . ' WHERE ' . Scope::sql( 'p' ) . ' AND ' . Scope::type_sql( 'p' ) . ' GROUP BY m.content_class',
+            $m, $wpdb->posts
+        ) ) as $r ) {
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+            if ( isset( $roles[ $r->role ] ) ) $roles[ $r->role ] = (int) $r->n;
+        }
+        $e    = $seo['expr'];
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $meta = $wpdb->get_row( $wpdb->prepare(
+            'SELECT COUNT(*) AS n, SUM(' . SeoMeta::sql_status( $e, 'missing' ) . ') AS missing, SUM(' . SeoMeta::sql_status( $e, 'weak' ) . ') AS weak'
+            . ' FROM %i m INNER JOIN %i p ON p.ID = m.post_id ' . $seo['join'] . ' WHERE ' . Scope::sql( 'p' ) . ' AND ' . Scope::type_sql( 'p' ),
+            $m, $wpdb->posts
+        ) );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        $missing = (int) ( $meta->missing ?? 0 );
+        $weak    = (int) ( $meta->weak ?? 0 ); // since 0.98.185 SeoMeta's "weak" no longer includes "missing" (0.98.200: was subtracted twice)
+
+        $links = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}clusterlane_links` WHERE target_type = 'post'" );
+
+        // Roles per cluster in one query.
+        $by_cluster = [];
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT cluster_key, content_class, COUNT(*) AS n FROM %i GROUP BY cluster_key, content_class', $cpc ) ) as $r ) {
+            $by_cluster[ $r->cluster_key ][ $r->content_class ] = (int) $r->n;
+        }
+
+        $archetypes = [];
+        $list       = [];
+        foreach ( $clusters as $c ) {
+            $arch = (string) ( $c->archetype ?: 'Unclassified' );
+            $archetypes[ $arch ] = ( $archetypes[ $arch ] ?? 0 ) + 1;
+            $r = $by_cluster[ $c->cluster_key ] ?? [];
+            $list[] = [
+                'k' => (string) $c->cluster_key,
+                'l' => (string) $c->cluster_label,
+                't' => (string) $c->cluster_type,
+                'n' => (int) $c->total_members,
+                'h' => (int) $c->health_score,
+                'a' => $arch,
+                'r' => [
+                    'pillar'  => (int) ( $r['pillar'] ?? 0 ),
+                    'support' => (int) ( $r['support'] ?? 0 ),
+                    'light'   => (int) ( $r['light'] ?? 0 ),
+                    'orphan'  => (int) ( $r['orphan'] ?? 0 ),
+                ],
+            ];
+        }
+
+        return [
+            'v'        => 1,
+            'site'     => [
+                'articles'     => (int) $summary['total_posts'],
+                'scanned'      => (int) $summary['total_scanned'],
+                'clusters'     => (int) $csum['total_clusters'],
+                'health'       => ClusterEngine::weighted_health( $clusters ),
+                'avg_health'   => (float) $csum['avg_health'],
+                'orphans'      => (int) $summary['orphan_count'],
+                'avg_inbound'  => (float) $summary['avg_inbound'],
+                'avg_outbound' => (float) $summary['avg_outbound'],
+                'avg_score'    => (float) $summary['avg_score'],
+                'links'        => $links,
+                'roles'        => $roles,
+                'meta'         => [ 'good' => max( 0, (int) ( $meta->n ?? 0 ) - $missing - $weak ), 'weak' => $weak, 'missing' => $missing ],
+                'archetypes'   => $archetypes,
+                'last_scan'    => $summary['last_scan'],
+            ],
+            'clusters' => $list,
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       Reading (for the report page)
+    ------------------------------------------------------------------ */
+
+    /**
+     * Headline figures per day, oldest first, for the last $days days.
+     * @return array<int,array<string,mixed>>
+     */
+    public static function series( int $days = 90 ): array {
+        global $wpdb;
+        $since = wp_date( 'Y-m-d', time() - max( 1, $days ) * DAY_IN_SECONDS );
+        $rows  = $wpdb->get_results( $wpdb->prepare(
+            "SELECT snap_date, metrics_version, articles, scanned, clusters, health, orphans, avg_inbound, links
+             FROM `{$wpdb->prefix}clusterlane_snapshots` WHERE snap_date >= %s ORDER BY snap_date ASC",
+            $since
+        ), ARRAY_A );
+        return array_map( static function ( array $r ): array {
+            foreach ( [ 'metrics_version', 'articles', 'scanned', 'clusters', 'health', 'orphans', 'links' ] as $k ) $r[ $k ] = (int) $r[ $k ];
+            $r['avg_inbound'] = (float) $r['avg_inbound'];
+            return $r;
+        }, (array) $rows );
+    }
+
+    /**
+     * The full snapshot on or just before $date (Y-m-d), or the latest one.
+     * @return array<string,mixed>|null
+     */
+    public static function get( string $date = '' ): ?array {
+        global $wpdb;
+        $t   = $wpdb->prefix . 'clusterlane_snapshots';
+        $row = $date !== ''
+            ? $wpdb->get_row( $wpdb->prepare( 'SELECT snap_date, taken_at, metrics_version, data FROM %i WHERE snap_date <= %s ORDER BY snap_date DESC LIMIT 1', $t, $date ) )
+            : $wpdb->get_row( $wpdb->prepare( 'SELECT snap_date, taken_at, metrics_version, data FROM %i ORDER BY snap_date DESC LIMIT 1', $t ) );
+        if ( ! $row ) return null;
+        $data = json_decode( (string) $row->data, true );
+        if ( ! is_array( $data ) ) return null;
+        $data['date']            = (string) $row->snap_date;
+        $data['taken_at']        = (string) $row->taken_at;
+        $data['metrics_version'] = (int) $row->metrics_version;
+        return $data;
+    }
+
+    /** The oldest snapshot's date (Y-m-d), or null. */
+    public static function earliest(): ?string {
+        global $wpdb;
+        $d = $wpdb->get_var( "SELECT MIN(snap_date) FROM `{$wpdb->prefix}clusterlane_snapshots`" );
+        return $d ? (string) $d : null;
+    }
+
+    /** @return array{count:int, first:?string, last:?string} For the support report. */
+    public static function stats(): array {
+        global $wpdb;
+        $r = $wpdb->get_row( "SELECT COUNT(*) AS n, MIN(snap_date) AS first, MAX(snap_date) AS last FROM `{$wpdb->prefix}clusterlane_snapshots`" );
+        return [ 'count' => (int) ( $r->n ?? 0 ), 'first' => $r->first ?? null, 'last' => $r->last ?? null ];
+    }
+
+    /** Keep daily rows for KEEP_DAILY_DAYS, then one a week (Mondays). */
+    public static function prune(): void {
+        global $wpdb;
+        $before = wp_date( 'Y-m-d', time() - self::KEEP_DAILY_DAYS * DAY_IN_SECONDS );
+        $wpdb->query( $wpdb->prepare(
+            "DELETE FROM `{$wpdb->prefix}clusterlane_snapshots` WHERE snap_date < %s AND DAYOFWEEK(snap_date) <> 2",
+            $before
+        ) );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-html-places.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-html-places.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-html-places.php	(revision 3751027)
@@ -0,0 +1,232 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * HtmlPlaces (0.98.158) — where in an article's saved HTML Clusterlane may add
+ * something, and where it must not.
+ *
+ * Articles look "any which way": text without <p> (wpautop sites), <p> without
+ * </p>, cards and boxes from other plugins, page-builder shortcodes that wrap
+ * the text ([vc_column_text], [et_pb_text] …), block comments, scripts. A new
+ * See-also card must land in the article's own flow, never inside one of those
+ * boxes; a Text Match link never inside code, a script or a button.
+ *
+ * Depth = how many "boxes" surround a byte offset:
+ *   - HTML boxes (BOX_TAGS) — measured with WordPress's own HTML parser
+ *     (WP_HTML_Processor, 6.5+) when it is there: an invisible probe comment is
+ *     put at every offset in a copy, the copy is parsed once, and each probe's
+ *     breadcrumbs say what it sits in — the browser's view, including implied
+ *     closing tags. A probe the parser never reaches (inside a script, a
+ *     textarea, an attribute) is "unknown" and never allowed. Without the
+ *     parser (older WordPress) or when it gives up, a tag-counting fallback.
+ *   - Enclosing shortcodes ([x]…[/x]) — one level each; a self-closing one
+ *     ([gallery id="1"]) has no inside and adds nothing.
+ *
+ * Covered by tests/insertion.php (Pro zip only), run with `wp eval-file`.
+ */
+final class HtmlPlaces {
+
+    /** Elements that make a box: something inside one is not in the article's own flow. */
+    public const BOX_TAGS = [ 'DIV', 'BLOCKQUOTE', 'ASIDE', 'SECTION', 'FIGURE', 'FIGCAPTION', 'TABLE', 'UL', 'OL', 'LI', 'DL',
+        'DETAILS', 'SUMMARY', 'NAV', 'HEADER', 'FOOTER', 'ARTICLE', 'FORM', 'PRE', 'CODE', 'A', 'BUTTON', 'LABEL',
+        'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'IFRAME', 'VIDEO', 'AUDIO', 'OBJECT', 'SELECT', 'SVG', 'MATH' ];
+
+    /** Depth for an offset that cannot be placed (inside a script, a tag, an attribute …). */
+    public const UNKNOWN = 999;
+
+    private const PROBE = 'clusterlane-probe:';
+
+    /** Tests only: false forces the tag-counting fallback (as on WordPress < 6.5). */
+    public static bool $use_parser = true;
+
+    /**
+     * Box depth at each byte offset.
+     * @param int[] $offsets
+     * @return array<int,int> same keys as $offsets
+     */
+    public static function depths( string $html, array $offsets ): array {
+        if ( ! $offsets ) return [];
+        $html_d = self::parser_depths( $html, $offsets ) ?? self::regex_depths( $html, $offsets );
+        $sc     = self::shortcode_ranges( $html );
+        $nogo   = self::no_go_ranges( $html );
+        $out    = [];
+        foreach ( $offsets as $k => $off ) {
+            $d = $html_d[ $k ] ?? self::UNKNOWN;
+            foreach ( $nogo as [ $s, $e ] ) if ( $off > $s && $off < $e ) $d = self::UNKNOWN; // in a script, code, comment …
+            if ( $d < self::UNKNOWN ) {
+                foreach ( $sc as [ $s, $e ] ) if ( $off >= $s && $off <= $e ) $d++; // inner range, both ends inside
+                if ( self::inside_tag( $html, $off ) ) $d = self::UNKNOWN;
+            }
+            $out[ $k ] = $d;
+        }
+        return $out;
+    }
+
+    public static function depth_at( string $html, int $offset ): int {
+        return self::depths( $html, [ $offset ] )[0];
+    }
+
+    /**
+     * The article's own flow among candidate places (pairs [text, offset] as
+     * preg_match_all gives them): the ones at the shallowest depth. When there
+     * is article text outside every box, only depth 0 counts — a lone <p> in a
+     * card is not the article. A page wrapped in one <div> or one builder
+     * shortcode keeps its shallowest level.
+     *
+     * @param array<int,array{0:string,1:int}> $places
+     * @return array<int,array{0:string,1:int}>
+     */
+    public static function top_level( string $html, array $places ): array {
+        if ( ! $places ) return [];
+        $depths = self::depths( $html, array_map( fn( $p ) => (int) $p[1], $places ) );
+        $known  = array_filter( $depths, fn( $d ) => $d < self::UNKNOWN );
+        if ( ! $known ) return [];
+        $min = min( $known );
+        if ( $min > 0 && self::outside_text_len( $html ) > 40 ) return [];
+        $keep = [];
+        foreach ( $places as $i => $p ) if ( $depths[ $i ] === $min ) $keep[] = $p;
+        return $keep;
+    }
+
+    /** The shallowest depth the article's text sits at (0 unless everything is wrapped). */
+    public static function flow_depth( string $html ): int {
+        if ( self::outside_text_len( $html ) > 40 ) return 0;
+        preg_match_all( '/<p\b[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE );
+        $known = array_filter( self::depths( $html, array_map( fn( $x ) => $x[1], $m[0] ) ), fn( $d ) => $d < self::UNKNOWN );
+        return $known ? min( $known ) : 0;
+    }
+
+    /**
+     * Byte ranges where nothing may be linked or inserted: scripts, styles,
+     * code, preformatted text, form controls, buttons and HTML comments
+     * (block comments carry JSON attributes).
+     * @return array<int,array{0:int,1:int}>
+     */
+    public static function no_go_ranges( string $html ): array {
+        $out = [];
+        foreach ( [ '/<(script|style|code|pre|textarea|button|select|svg)\b[^>]*>.*?<\/\1>/is', '/<!--.*?-->/s' ] as $rx ) {
+            if ( preg_match_all( $rx, $html, $m, PREG_OFFSET_CAPTURE ) ) {
+                foreach ( $m[0] as $r ) $out[] = [ $r[1], $r[1] + strlen( $r[0] ) ];
+            }
+        }
+        return $out;
+    }
+
+    /* ------------------------------------------------------------------
+       Internals
+    ------------------------------------------------------------------ */
+
+    /** Offsets between < and > of a tag (not a comment) are never a place. */
+    private static function inside_tag( string $html, int $off ): bool {
+        $before = substr( $html, 0, $off );
+        $lo = strrpos( $before, '<' );
+        $lc = strrpos( $before, '>' );
+        return $lo !== false && ( $lc === false || $lo > $lc );
+    }
+
+    /** @return array<int,int>|null  null: parser missing or it gave up. */
+    private static function parser_depths( string $html, array $offsets ): ?array {
+        if ( ! self::$use_parser || ! class_exists( '\WP_HTML_Processor' ) || ! method_exists( '\WP_HTML_Processor', 'next_token' ) || ! method_exists( '\WP_HTML_Processor', 'get_breadcrumbs' ) ) {
+            return null;
+        }
+        // A copy with a probe at every offset, inserted from the back so offsets stay valid.
+        $order = $offsets;
+        arsort( $order );
+        $probed = $html;
+        foreach ( $order as $k => $off ) {
+            $probed = substr( $probed, 0, $off ) . '<!--' . self::PROBE . $k . '-->' . substr( $probed, $off );
+        }
+        try {
+            $p = \WP_HTML_Processor::create_fragment( $probed );
+            if ( ! $p ) return null;
+            $box   = array_flip( self::BOX_TAGS );
+            $found = [];
+            while ( $p->next_token() ) {
+                if ( $p->get_token_type() !== '#comment' ) continue;
+                $t = $p->get_modifiable_text();
+                if ( ! str_starts_with( $t, self::PROBE ) ) continue;
+                $n = 0;
+                foreach ( $p->get_breadcrumbs() as $tag ) if ( isset( $box[ $tag ] ) ) $n++;
+                $found[ substr( $t, strlen( self::PROBE ) ) ] = $n;
+            }
+            if ( $p->get_last_error() !== null ) return null; // unsupported markup: use the fallback
+        } catch ( \Throwable $e ) {
+            return null;
+        }
+        $out = [];
+        foreach ( $offsets as $k => $off ) $out[ $k ] = $found[ (string) $k ] ?? self::UNKNOWN;
+        return $out;
+    }
+
+    /** Tag counting (no parser): opening minus closing box tags before each offset. */
+    private static function regex_depths( string $html, array $offsets ): array {
+        $names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) );
+        preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $html, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
+        $out = [];
+        foreach ( $offsets as $k => $off ) {
+            $d = 0;
+            foreach ( $tags as $t ) {
+                if ( $t[0][1] >= $off ) break;
+                if ( $t[3][0] === '/' ) continue;
+                $d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) );
+            }
+            $out[ $k ] = $d;
+        }
+        return $out;
+    }
+
+    /**
+     * Inner byte ranges [after the opening tag, before the closing tag) of
+     * every enclosing shortcode, nested ones included.
+     * @return array<int,array{0:int,1:int}>
+     */
+    public static function shortcode_ranges( string $html ): array {
+        if ( ! preg_match_all( '/\[(\/)?([a-zA-Z][\w-]*)(?:\s[^\]]*)?(\/)?\]/', $html, $m, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ) ) return [];
+        $out   = [];
+        $stack = [];
+        foreach ( $m as $t ) {
+            $name  = strtolower( $t[2][0] );
+            $close = $t[1][0] === '/';
+            if ( ! $close ) {
+                if ( isset( $t[3] ) && $t[3][0] === '/' ) continue;                          // [x /]
+                if ( stripos( $html, '[/' . $name . ']', $t[0][1] ) === false ) continue;     // self-closing
+                $stack[] = [ $name, $t[0][1] + strlen( $t[0][0] ) ];
+                continue;
+            }
+            for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) {
+                if ( $stack[ $i ][0] !== $name ) continue;
+                $out[] = [ $stack[ $i ][1], $t[0][1] ];
+                array_splice( $stack, $i );
+                break;
+            }
+        }
+        return $out;
+    }
+
+    /** Characters of article text outside every box and enclosing shortcode. */
+    private static function outside_text_len( string $html ): int {
+        // Remove enclosing shortcodes (outermost first: longest ranges cover the nested ones).
+        $sc = self::shortcode_ranges( $html );
+        usort( $sc, fn( $a, $b ) => $b[0] <=> $a[0] );
+        $text = $html;
+        foreach ( $sc as [ $s, $e ] ) {
+            if ( $e <= strlen( $text ) ) $text = substr( $text, 0, $s ) . str_repeat( ' ', $e - $s ) . substr( $text, $e );
+        }
+        // Then everything inside box elements (tag counting is enough for a length estimate).
+        $names = implode( '|', array_map( 'strtolower', self::BOX_TAGS ) );
+        preg_match_all( '#<(/?)(' . $names . ')\b[^>]*?(/?)>#i', $text, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
+        $outside = '';
+        $d = 0; $last = 0;
+        foreach ( $tags as $t ) {
+            if ( $d === 0 ) $outside .= substr( $text, $last, $t[0][1] - $last );
+            if ( $t[3][0] !== '/' ) $d = max( 0, $d + ( $t[1][0] === '/' ? -1 : 1 ) );
+            $last = $t[0][1] + strlen( $t[0][0] );
+        }
+        if ( $d === 0 ) $outside .= substr( $text, $last );
+        $outside = preg_replace( '/<!--.*?-->|\[[^\]]*\]/s', ' ', $outside );
+        $outside = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $outside ) ) );
+        return function_exists( 'mb_strlen' ) ? mb_strlen( $outside, 'UTF-8' ) : strlen( $outside );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-hub-clusters.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-hub-clusters.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-hub-clusters.php	(revision 3751027)
@@ -0,0 +1,346 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * HubClusters (0.98.180) — pages as clusters.
+ *
+ * Some sites are built from post types that point at each other through
+ * custom fields instead of categories: an area names its island
+ * (area_island), an attraction names its area (place_area). Each island is
+ * then a natural cluster — the island's page, the areas that point to it and
+ * the attractions that point to those areas — though no taxonomy says so.
+ *
+ * relations() finds such fields by themselves: custom fields of the scanned
+ * post types whose values are IDs of other published pages (ACF post object
+ * and relationship fields, or any field holding a post ID). A field only
+ * counts when nearly all its values are pages of one post type, so a number
+ * that happens to equal a post ID doesn't make a relation.
+ *
+ * A post type ticked in Cluster Admin (setting enabled_hubs) makes every one
+ * of its pages a cluster: cluster_type "hub_{post type}", cluster_key
+ * "hub_{post ID}", term_id = the post ID. Members are worked out on every
+ * sync and Metrics run, two steps deep, so new content joins by itself.
+ */
+final class HubClusters {
+
+    const PREFIX       = 'hub_';
+    const CACHE_OPTION = 'clusterlane_hub_relations';
+
+    /** Share of a field's values that must be pages of the same post type. */
+    private const MIN_SHARE = 0.8;
+
+    /** Fewest pages that must use a field before it counts. */
+    private const MIN_USES = 3;
+
+    /** Steps from the hub page: areas point to the island (1), attractions to the areas (2). */
+    private const DEPTH = 2;
+
+    public static function is_hub_type( string $cluster_type ): bool {
+        return strpos( $cluster_type, self::PREFIX ) === 0;
+    }
+
+    public static function post_type_of( string $cluster_type ): string {
+        return substr( $cluster_type, strlen( self::PREFIX ) );
+    }
+
+    /** The post types whose pages are clusters (Cluster Admin, onboarding). @return string[] */
+    public static function enabled(): array {
+        $s = Settings::get_instance();
+        if ( ! $s ) return [];
+        $types = array_map( 'sanitize_key', (array) $s->get( 'enabled_hubs', [] ) );
+        return array_values( array_intersect( $types, array_keys( self::candidates() ) ) );
+    }
+
+    /**
+     * Fields that point from one scanned post type to another.
+     *
+     * @return array<int, array{from: string, key: string, to: string, uses: int}>
+     */
+    public static function relations( bool $fresh = false, ?array $types = null ): array {
+        if ( $types !== null ) { // onboarding: the post types being picked, not saved yet
+            $types = array_values( array_filter( array_map( 'sanitize_key', $types ) ) );
+            return $types ? self::detect( $types ) : [];
+        }
+        $types = array_values( array_filter( array_map( 'sanitize_key', Scope::post_types() ) ) );
+        $sig   = md5( implode( ',', $types ) );
+        if ( ! $fresh ) {
+            $c = get_option( self::CACHE_OPTION );
+            if ( is_array( $c ) && ( $c['sig'] ?? '' ) === $sig && isset( $c['rel'] ) && time() - (int) ( $c['at'] ?? 0 ) < DAY_IN_SECONDS ) return $c['rel'];
+        }
+        $rel = $types ? self::detect( $types ) : [];
+        update_option( self::CACHE_OPTION, [ 'sig' => $sig, 'at' => time(), 'rel' => $rel ], false );
+        return $rel;
+    }
+
+    /** @param string[] $types @return array<int, array{from: string, key: string, to: string, uses: int}> */
+    private static function detect( array $types ): array {
+        global $wpdb;
+        // Per field and source post type: how many values there are, and where they point.
+        $totals = []; // "from|key" => values
+        $hits   = []; // "from|key" => [ to => count ]
+
+        // A single ID: "2803".
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            "SELECT p.post_type AS src, pm.meta_key AS k, t.post_type AS dst, t.post_status AS st, COUNT(*) AS n
+             FROM {$wpdb->postmeta} pm
+             JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ")
+             LEFT JOIN {$wpdb->posts} t ON t.ID = CAST( pm.meta_value AS UNSIGNED ) AND t.ID <> p.ID
+             WHERE pm.meta_key NOT LIKE %s AND pm.meta_value REGEXP %s
+             GROUP BY src, k, dst, st",
+            array_merge( $types, [ $wpdb->esc_like( '_' ) . '%', '^[1-9][0-9]{0,18}$' ] )
+        ) );
+        foreach ( (array) $rows as $r ) {
+            $id = $r->src . '|' . $r->k;
+            $totals[ $id ] = ( $totals[ $id ] ?? 0 ) + (int) $r->n;
+            if ( $r->dst && $r->st === 'publish' && in_array( $r->dst, $types, true ) ) $hits[ $id ][ $r->dst ] = ( $hits[ $id ][ $r->dst ] ?? 0 ) + (int) $r->n;
+        }
+
+        // A list of IDs (ACF relationship): a:2:{i:0;s:4:"2803";i:1;s:4:"2808";}
+        $lists = $wpdb->get_results( $wpdb->prepare(
+            "SELECT p.post_type AS src, pm.meta_key AS k, pm.meta_value AS v
+             FROM {$wpdb->postmeta} pm
+             JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_status = 'publish' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')
+             WHERE pm.meta_key NOT LIKE %s AND pm.meta_value LIKE %s AND CHAR_LENGTH( pm.meta_value ) < 4000
+             LIMIT 20000',
+            array_merge( $types, [ $wpdb->esc_like( '_' ) . '%', $wpdb->esc_like( 'a:' ) . '%' ] )
+        ) );
+        $ids = []; $by = [];
+        foreach ( (array) $lists as $r ) {
+            $v = is_serialized( $r->v ) ? @unserialize( $r->v, [ 'allowed_classes' => false ] ) : null; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
+            if ( ! is_array( $v ) || ! $v ) continue;
+            $vals = array_values( array_filter( $v, fn( $x ) => is_scalar( $x ) && preg_match( '/^[1-9][0-9]{0,18}$/', (string) $x ) ) );
+            if ( count( $vals ) !== count( $v ) ) continue; // not a list of IDs
+            $id = $r->src . '|' . $r->k;
+            foreach ( $vals as $x ) { $by[ $id ][] = (int) $x; $ids[ (int) $x ] = true; }
+        }
+        if ( $ids ) {
+            $type_of = [];
+            foreach ( array_chunk( array_keys( $ids ), 1000 ) as $chunk ) {
+                $chunk = array_map( 'intval', $chunk );
+                foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT ID, post_type FROM {$wpdb->posts} WHERE post_status = 'publish' AND ID IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')', $chunk ) ) as $p ) {
+                    $type_of[ (int) $p->ID ] = $p->post_type;
+                }
+            }
+            foreach ( $by as $id => $vals ) {
+                foreach ( $vals as $x ) {
+                    $totals[ $id ] = ( $totals[ $id ] ?? 0 ) + 1;
+                    $dst = $type_of[ $x ] ?? '';
+                    if ( $dst && in_array( $dst, $types, true ) ) $hits[ $id ][ $dst ] = ( $hits[ $id ][ $dst ] ?? 0 ) + 1;
+                }
+            }
+        }
+
+        $out = [];
+        foreach ( $hits as $id => $dsts ) {
+            arsort( $dsts );
+            $dst = (string) array_key_first( $dsts );
+            $n   = (int) $dsts[ $dst ];
+            if ( $n < self::MIN_USES || $n < self::MIN_SHARE * (int) $totals[ $id ] ) continue;
+            [ $src, $key ] = explode( '|', $id, 2 );
+            if ( $src === $dst ) continue; // a page pointing to pages of its own type is no hierarchy
+            $out[] = [ 'from' => $src, 'key' => $key, 'to' => $dst, 'uses' => $n ];
+        }
+        usort( $out, fn( $a, $b ) => $b['uses'] <=> $a['uses'] );
+        return $out;
+    }
+
+    /**
+     * Post types other content points to — what Cluster Admin offers — with
+     * the paths into them: Islands ← Areas (area_island) ← Attractions (place_area).
+     *
+     * @return array<string, array{label: string, singular: string, pages: int, paths: array<int, array{from: string, key: string, via?: string, via_key?: string}>}>
+     */
+    public static function candidates( ?array $types = null ): array {
+        $rel = self::relations( false, $types );
+        $out = [];
+        foreach ( $rel as $r ) {
+            $obj = get_post_type_object( $r['to'] );
+            if ( ! $obj ) continue;
+            if ( ! isset( $out[ $r['to'] ] ) ) {
+                $out[ $r['to'] ] = [
+                    'label'    => html_entity_decode( (string) $obj->labels->name, ENT_QUOTES, 'UTF-8' ),
+                    'singular' => html_entity_decode( (string) $obj->labels->singular_name, ENT_QUOTES, 'UTF-8' ),
+                    'pages'    => (int) ( wp_count_posts( $r['to'] )->publish ?? 0 ),
+                    'paths'    => [],
+                ];
+            }
+            $out[ $r['to'] ]['paths'][] = [ 'from' => $r['from'], 'key' => $r['key'] ];
+            // One step further: what points at the pages that point here.
+            foreach ( $rel as $r2 ) {
+                if ( $r2['to'] === $r['from'] && $r2['from'] !== $r['to'] ) {
+                    $out[ $r['to'] ]['paths'][] = [ 'from' => $r2['from'], 'key' => $r2['key'], 'via' => $r['from'], 'via_key' => $r['key'] ];
+                }
+            }
+        }
+        return array_filter( $out, fn( $c ) => $c['pages'] >= 2 );
+    }
+
+    /**
+     * The pages of a hub cluster: the hub page, the pages pointing to it, and
+     * the pages pointing to those. Published and in scope only.
+     *
+     * @return int[]
+     */
+    public static function members( int $hub_id ): array {
+        $hub = get_post( $hub_id );
+        if ( ! $hub || Scope::excludes( $hub ) ) return [];
+        $rel     = self::relations();
+        $members = [ $hub_id => true ];
+        $level   = [ $hub->post_type => [ $hub_id ] ];
+        for ( $d = 0; $d < self::DEPTH && $level; $d++ ) {
+            $next = [];
+            foreach ( $level as $to_type => $ids ) {
+                foreach ( $rel as $r ) {
+                    if ( $r['to'] !== $to_type ) continue;
+                    foreach ( self::pointing_to( $r['from'], $r['key'], $ids ) as $pid ) {
+                        if ( isset( $members[ $pid ] ) ) continue;
+                        $members[ $pid ] = true;
+                        $next[ $r['from'] ][] = $pid;
+                    }
+                }
+            }
+            $level = $next;
+        }
+        return array_map( 'intval', array_keys( $members ) );
+    }
+
+    /** Published, in-scope pages of $type whose field $key holds one of $ids. @param int[] $ids @return int[] */
+    private static function pointing_to( string $type, string $key, array $ids ): array {
+        global $wpdb;
+        $ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
+        if ( ! $ids ) return [];
+        $out = [];
+        foreach ( array_chunk( $ids, 200 ) as $chunk ) {
+            // The field as a single ID ("12") or inside a serialized list (s:2:"12"; or i:12;).
+            $likes = [];
+            foreach ( $chunk as $i ) {
+                $likes[] = '%' . $wpdb->esc_like( '"' . $i . '"' ) . '%';
+                $likes[] = '%' . $wpdb->esc_like( 'i:' . $i . ';' ) . '%';
+            }
+            $args = array_merge( [ $key, $type ], array_map( 'strval', $chunk ), [ $wpdb->esc_like( 'a:' ) . '%' ], $likes );
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $rows = $wpdb->get_col( $wpdb->prepare(
+                "SELECT DISTINCT p.ID FROM {$wpdb->postmeta} pm
+                 JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+                 WHERE pm.meta_key = %s AND p.post_type = %s AND " . Scope::sql( 'p' ) . '
+                   AND ( pm.meta_value IN (' . implode( ',', array_fill( 0, count( $chunk ), '%s' ) ) . ')
+                         OR ( pm.meta_value LIKE %s AND ( ' . implode( ' OR ', array_fill( 0, count( $likes ), 'pm.meta_value LIKE %s' ) ) . ' ) ) )',
+                $args
+            ) ); // Scope::sql() has no values
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+            foreach ( (array) $rows as $id ) $out[] = (int) $id;
+        }
+        return $out;
+    }
+
+    /**
+     * Hub clusters an article belongs to: its own page when its type is a hub,
+     * and the hubs its fields lead to, up to DEPTH steps.
+     *
+     * @return string[] cluster keys
+     */
+    public static function keys_for( int $post_id ): array {
+        $hubs = self::enabled();
+        if ( ! $hubs ) return [];
+        $post = get_post( $post_id );
+        if ( ! $post ) return [];
+        $rel   = self::relations();
+        $keys  = [];
+        $level = [ $post_id => $post->post_type ];
+        for ( $d = 0; $d <= self::DEPTH && $level; $d++ ) {
+            $next = [];
+            foreach ( $level as $id => $type ) {
+                if ( in_array( $type, $hubs, true ) ) $keys[] = self::PREFIX . $id;
+                if ( $d === self::DEPTH ) continue;
+                foreach ( $rel as $r ) {
+                    if ( $r['from'] !== $type ) continue;
+                    $v    = get_post_meta( $id, $r['key'], true );
+                    $vals = is_array( $v ) ? $v : [ $v ];
+                    foreach ( $vals as $x ) {
+                        if ( is_scalar( $x ) && preg_match( '/^[1-9][0-9]*$/', (string) $x ) && get_post_type( (int) $x ) === $r['to'] ) $next[ (int) $x ] = $r['to'];
+                    }
+                }
+            }
+            $level = $next;
+        }
+        return array_values( array_unique( $keys ) );
+    }
+
+    /**
+     * The page at the top of this page's hierarchy: follow its fields upward
+     * (area → its island) until nothing points further. The page itself when
+     * it points nowhere. Used to group page-clusters into sections (0.98.181).
+     */
+    public static function top_of( int $post_id ): int {
+        $rel  = self::relations();
+        $seen = [ $post_id => true ];
+        $cur  = $post_id;
+        for ( $i = 0; $i < 6; $i++ ) {
+            $type = get_post_type( $cur );
+            $up   = 0;
+            foreach ( $rel as $r ) {
+                if ( $r['from'] !== $type ) continue;
+                $v = get_post_meta( $cur, $r['key'], true );
+                foreach ( is_array( $v ) ? $v : [ $v ] as $x ) {
+                    if ( is_scalar( $x ) && preg_match( '/^[1-9][0-9]*$/', (string) $x ) && get_post_type( (int) $x ) === $r['to'] && get_post_status( (int) $x ) === 'publish' ) { $up = (int) $x; break 2; }
+                }
+            }
+            if ( ! $up || isset( $seen[ $up ] ) ) break;
+            $seen[ $up ] = true;
+            $cur = $up;
+        }
+        return $cur;
+    }
+
+    /** Pages that must point to a page before it is a cluster: the Cluster Admin minimum, at least 2. */
+    public static function min_pointing(): int {
+        $s = Settings::get_instance();
+        return max( 2, $s ? $s->min_term_count() : 2 );
+    }
+
+    /**
+     * Create or refresh a cluster for every page of the ticked post types and
+     * switch off hub clusters no longer wanted. @return int clusters created
+     */
+    public static function sync(): int {
+        global $wpdb;
+        $table   = $wpdb->prefix . 'clusterlane_clusters';
+        $types   = self::enabled();
+        $keep    = [];
+        $created = 0;
+        foreach ( $types as $type ) {
+            $pages = get_posts( Scope::query_args( [
+                'post_type' => $type, 'post_status' => 'publish', 'posts_per_page' => -1,
+                'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true,
+            ] ) );
+            foreach ( $pages as $p ) {
+                // A page with one page pointing to it is no cluster — a pair says nothing (0.98.181).
+                if ( count( self::members( (int) $p->ID ) ) - 1 < self::min_pointing() ) continue;
+                $key    = self::PREFIX . (int) $p->ID;
+                $keep[] = $key;
+                $label  = html_entity_decode( get_the_title( $p ), ENT_QUOTES, 'UTF-8' );
+                $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE cluster_key = %s', $table, $key ) );
+                if ( $exists ) {
+                    $wpdb->update( $table, [ 'cluster_label' => $label, 'cluster_type' => self::PREFIX . $type, 'term_id' => (int) $p->ID, 'is_active' => 1 ], [ 'cluster_key' => $key ] );
+                } else {
+                    $wpdb->insert( $table, [ 'cluster_key' => $key, 'cluster_label' => $label, 'cluster_type' => self::PREFIX . $type, 'term_id' => (int) $p->ID, 'is_active' => 1 ] );
+                    $created++;
+                }
+            }
+        }
+        $like = $wpdb->esc_like( self::PREFIX ) . '%';
+        if ( $keep ) {
+            $wpdb->query( $wpdb->prepare( 'UPDATE %i SET is_active = 0 WHERE cluster_type LIKE %s AND cluster_key NOT IN (' . implode( ',', array_fill( 0, count( $keep ), '%s' ) ) . ')', array_merge( [ $table, $like ], $keep ) ) );
+        } else {
+            $wpdb->query( $wpdb->prepare( 'UPDATE %i SET is_active = 0 WHERE cluster_type LIKE %s', $table, $like ) );
+        }
+        return $created;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-installer.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-installer.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-installer.php	(revision 3751027)
@@ -0,0 +1,476 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Installer
+ *
+ * Handles database table creation and upgrades.
+ * Called on plugin activation and checked on each boot
+ * (dbDelta is idempotent — safe to run on every request if needed,
+ * though we only run it when the stored version differs).
+ *
+ * Table prefix strategy:
+ *   {wp_prefix}clusterlane_{table}
+ *   e.g. stock_clusterlane_links, stock_clusterlane_clusters …
+ * This keeps Clusterlane fully isolated from any other plugin using the same DB.
+ */
+class Installer {
+
+    /** Option key that stores the installed schema version. */
+    const VERSION_KEY = 'clusterlane_db_version';
+
+    /** Increment this whenever the schema changes. */
+    const SCHEMA_VERSION = '8'; // 8: cluster_type fits any taxonomy name (0.98.177); 7: clusterlane_snapshots + clusterlane_activity (0.98.124)
+
+    /* ------------------------------------------------------------------
+       Public API
+    ------------------------------------------------------------------ */
+
+    public static function activate(): void {
+        require_once __DIR__ . '/class-capabilities.php';
+        Capabilities::install();
+        self::create_tables();
+        update_option( self::VERSION_KEY, self::SCHEMA_VERSION, false );
+    }
+
+    public static function deactivate(): void {
+        // Data is kept on deactivation (removed only by uninstall.php);
+        // only the daily snapshot event is unscheduled.
+        wp_clear_scheduled_hook( 'clusterlane_daily_snapshot' );
+        wp_clear_scheduled_hook( 'clusterlane_read_pages' ); // published pages still queued (0.98.178)
+    }
+
+    /**
+     * Called on every boot; runs migrations only when the stored version
+     * is behind the current schema version.
+     */
+    public static function maybe_upgrade(): void {
+        self::data_migrations();
+        if ( get_option( self::VERSION_KEY ) === self::SCHEMA_VERSION ) return;
+        self::create_tables();
+        update_option( self::VERSION_KEY, self::SCHEMA_VERSION, false );
+    }
+
+    /** One-time data fixes that don't change the schema (per site, option-gated). */
+    private static function data_migrations(): void {
+        $done = (int) get_option( 'clusterlane_data_version', 0 );
+        if ( $done >= 3 ) return;
+        global $wpdb;
+        if ( $done < 1 ) {
+            $table = $wpdb->prefix . 'clusterlane_clusters';
+            if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
+                // 1: computed clusters with no pattern: "Unclassified" → "Mixed".
+                $wpdb->query( $wpdb->prepare( "UPDATE %i SET archetype = 'Mixed' WHERE archetype = 'Unclassified'", $table ) );
+            }
+        }
+        // 2 (0.98.128): undo the HTML entities the old sanitizer wrote into saved
+        // prompts, stop words and SEO instructions; a prompt that is the default
+        // again stops being "Custom".
+        $s = $done < 2 ? get_option( 'clusterlane_settings', [] ) : null;
+        if ( is_array( $s ) ) {
+            require_once __DIR__ . '/class-settings.php';
+            $defaults = Settings::default_prompts_public();
+            foreach ( (array) ( $s['prompts'] ?? [] ) as $k => $v ) {
+                $v = (string) $v;
+                for ( $i = 0; $i < 5 && str_contains( $v, '\\"' ); $i++ ) $v = wp_unslash( $v );
+                $v = trim( Settings::heal_entities( $v ) );
+                if ( $v === '' || $v === trim( (string) ( $defaults[ $k ] ?? '' ) ) ) unset( $s['prompts'][ $k ] );
+                else $s['prompts'][ $k ] = $v;
+            }
+            foreach ( [ 'tms_stop_words', 'seo_ai_instructions' ] as $k ) {
+                if ( isset( $s[ $k ] ) && is_string( $s[ $k ] ) ) $s[ $k ] = Settings::heal_entities( $s[ $k ] );
+            }
+            update_option( 'clusterlane_settings', $s );
+        }
+        // 3 (0.98.145): site-wide roles from the clusters' roles — no more
+        // "pillar" for every article with 5+ inbound links.
+        $pm = $wpdb->prefix . 'clusterlane_post_metrics';
+        if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $pm ) ) === $pm ) {
+            require_once __DIR__ . '/class-cluster-engine.php';
+            ClusterEngine::sync_site_roles();
+        }
+        update_option( 'clusterlane_data_version', 3, false );
+    }
+
+    /**
+     * Returns true if all core Clusterlane tables are present in the database.
+     * Used at boot time to catch a failed or incomplete installation
+     * (e.g. a MySQL timeout during activation) and show a clear admin
+     * notice rather than letting the plugin crash with a DB error.
+     */
+    public static function tables_exist(): bool {
+        global $wpdb;
+        $required = [
+            $wpdb->prefix . 'clusterlane_clusters',
+            $wpdb->prefix . 'clusterlane_post_metrics',
+            $wpdb->prefix . 'clusterlane_links',
+        ];
+        foreach ( $required as $table ) {
+            if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /* ------------------------------------------------------------------
+       Schema
+    ------------------------------------------------------------------ */
+
+    private static function create_tables(): void {
+        global $wpdb;
+        $charset = $wpdb->get_charset_collate();
+
+        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+        // ----------------------------------------------------------------
+        // clusterlane_links — internal link graph
+        // Each row = one <a href> from a source to a target.
+        //
+        // target_type distinguishes a link to a post from a link to a
+        // taxonomy term's archive page (e.g. the category hub for a
+        // cluster). This is NOT cosmetic: WordPress term IDs and post
+        // IDs are independent sequences and can collide numerically, so
+        // every query against target_id must also filter on target_type
+        // or it can silently match the wrong kind of row.
+        // source_type is included for symmetry / future use (e.g. if a
+        // page or a term archive is ever scanned as a source) but is
+        // always 'post' today, since only posts are scanned.
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_links (
+                id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                source_id     BIGINT UNSIGNED NOT NULL,
+                source_type   VARCHAR(10)     NOT NULL DEFAULT 'post',
+                target_id     BIGINT UNSIGNED NOT NULL,
+                target_type   VARCHAR(10)     NOT NULL DEFAULT 'post',
+                anchor_text   VARCHAR(255)    NOT NULL DEFAULT '',
+                link_type     VARCHAR(20)     NOT NULL DEFAULT 'inline',
+                scanned_at    DATETIME        NOT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_clusters — one row per cluster
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_clusters (
+                id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                cluster_key     VARCHAR(100)    NOT NULL,
+                cluster_label   VARCHAR(255)    NOT NULL DEFAULT '',
+                cluster_type    VARCHAR(32)     NOT NULL DEFAULT 'category',
+                term_id         BIGINT UNSIGNED          DEFAULT NULL,
+                is_active       TINYINT(1)      NOT NULL DEFAULT 1,
+                archetype       VARCHAR(50)              DEFAULT NULL,
+                health_score    TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                entropy_score   TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                gini_score      TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                density_score   TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                total_members   SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                last_computed   DATETIME                 DEFAULT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_post_metrics — per-article intelligence scores
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_post_metrics (
+                post_id           BIGINT UNSIGNED NOT NULL,
+                primary_cluster   VARCHAR(100)             DEFAULT NULL,
+                seo_score         TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                structure_score   TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                overall_score     TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                inbound_links     SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                outbound_links    SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                word_count        SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                image_count       SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                h2_count          SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                content_class     VARCHAR(20)              DEFAULT NULL,
+                last_scanned      DATETIME                 DEFAULT NULL,
+                PRIMARY KEY  (post_id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_ai_cache — cached AI responses (gap analysis, link suggestions)
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_ai_cache (
+                id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                cache_type      VARCHAR(30)     NOT NULL,
+                context_key     VARCHAR(255)    NOT NULL,
+                response_data   LONGTEXT        NOT NULL,
+                model_used      VARCHAR(100)             DEFAULT NULL,
+                prompt_hash     VARCHAR(32)              DEFAULT NULL,
+                created_at      DATETIME        NOT NULL,
+                expires_at      DATETIME                 DEFAULT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_cluster_post_class — per-cluster, cluster-relative role
+        // of each member post (pillar / support / light / orphan).
+        //
+        // A post's structural role only makes sense relative to the
+        // distribution of its OWN cluster (5 inbound links is a pillar
+        // in a sparse cluster, average in a dense one), and a post can
+        // belong to more than one cluster (multiple categories, or
+        // overlapping custom clusters) with a different role in each.
+        // So this is keyed on (cluster_key, post_id) rather than living
+        // as a single global column on clusterlane_post_metrics.
+        //
+        // Rows are fully recomputed and replaced every time
+        // Cluster_Engine::compute_cluster() runs for a given cluster_key.
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_cluster_post_class (
+                id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                cluster_key     VARCHAR(100)    NOT NULL,
+                post_id         BIGINT UNSIGNED NOT NULL,
+                content_class   VARCHAR(20)     NOT NULL DEFAULT 'light',
+                inbound_links   SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+                percentile      TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                computed_at     DATETIME                 DEFAULT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_custom_members — posts in manually created clusters
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_custom_members (
+                id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                cluster_key VARCHAR(100)    NOT NULL,
+                post_id     BIGINT UNSIGNED NOT NULL,
+                added_at    DATETIME        NOT NULL,
+                PRIMARY KEY (id)
+            ) $charset;
+        " );
+
+        // clusterlane_tms_skipped — persistently skipped Text Match pairs
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_tms_skipped (
+                id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                source_id   BIGINT UNSIGNED NOT NULL,
+                target_id   BIGINT UNSIGNED NOT NULL,
+                skipped_at  DATETIME        NOT NULL,
+                PRIMARY KEY (id),
+                UNIQUE KEY uniq_pair (source_id, target_id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_snapshots — the site's figures once a day (schema 7).
+        // One row per local date: a later capture the same day replaces
+        // it. Headline figures are columns (fast series for charts); the
+        // rest — roles, meta, archetypes and every cluster — is JSON in
+        // `data`. metrics_version marks formula changes, so a chart can
+        // tell "the calculation changed" from "the site changed".
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_snapshots (
+                id               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                snap_date        DATE            NOT NULL,
+                taken_at         DATETIME        NOT NULL,
+                source           VARCHAR(10)     NOT NULL DEFAULT 'cron',
+                metrics_version  SMALLINT UNSIGNED NOT NULL DEFAULT 1,
+                articles         INT UNSIGNED    NOT NULL DEFAULT 0,
+                scanned          INT UNSIGNED    NOT NULL DEFAULT 0,
+                clusters         INT UNSIGNED    NOT NULL DEFAULT 0,
+                health           TINYINT UNSIGNED NOT NULL DEFAULT 0,
+                orphans          INT UNSIGNED    NOT NULL DEFAULT 0,
+                avg_inbound      DECIMAL(7,1)    NOT NULL DEFAULT 0,
+                links            INT UNSIGNED    NOT NULL DEFAULT 0,
+                data             LONGTEXT        NOT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // ----------------------------------------------------------------
+        // clusterlane_activity — what was done through Clusterlane (schema 7).
+        // One row per write: a link added, a pillar chosen, a meta
+        // description written, a draft created. Undo sets undone_at
+        // instead of deleting, so totals can be counted net.
+        // ----------------------------------------------------------------
+        dbDelta( "
+            CREATE TABLE {$wpdb->prefix}clusterlane_activity (
+                id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+                action       VARCHAR(20)     NOT NULL,
+                tool         VARCHAR(20)     NOT NULL DEFAULT '',
+                post_id      BIGINT UNSIGNED NOT NULL DEFAULT 0,
+                target_id    BIGINT UNSIGNED NOT NULL DEFAULT 0,
+                cluster_key  VARCHAR(100)    NOT NULL DEFAULT '',
+                detail       VARCHAR(255)    NOT NULL DEFAULT '',
+                user_id      BIGINT UNSIGNED NOT NULL DEFAULT 0,
+                created_at   DATETIME        NOT NULL,
+                undone_at    DATETIME                 DEFAULT NULL,
+                PRIMARY KEY  (id)
+            ) $charset;
+        " );
+
+        // dbDelta above creates the tables and (on upgrade) adds any new
+        // columns — both of which it does reliably. It does NOT manage the
+        // secondary indexes: those are reconciled idempotently below, for
+        // the reasons documented on reconcile_indexes().
+        self::reconcile_indexes();
+    }
+
+    /* ------------------------------------------------------------------
+       Index management
+
+       dbDelta is notoriously unreliable at reconciling secondary indexes.
+       On an upgrade where an index's columns changed it compares the old
+       and new definitions loosely, decides they differ, and tries to ADD
+       the new one while the identically-named old one is still present —
+       producing "Duplicate key name '<name>'" and aborting the migration.
+       (That is exactly what broke the v2 -> v3 upgrade.)
+
+       So we keep indexes out of the dbDelta CREATE statements entirely and
+       manage them here: a declarative map of every secondary index, each
+       added only if it is genuinely absent. This is safe to run on every
+       activation and every boot-time upgrade — present indexes are left
+       untouched, missing ones are created.
+
+       CAVEAT — changing an existing index's columns: because a name match
+       counts as "already present", re-declaring an index with new columns
+       here will NOT alter it. To change one, drop it first with
+       drop_index_if_exists() in a versioned step (see pre_reconcile_migrations)
+       and let reconcile re-add the new definition.
+    ------------------------------------------------------------------ */
+
+    /**
+     * Every secondary index, keyed by bare table name (no prefix). Each
+     * value maps index_name => [ unique?, [ columns ] ].
+     */
+    private static function index_map(): array {
+        return [
+            'clusterlane_links' => [
+                'source_target' => [ true, [ 'source_id', 'target_type', 'target_id' ] ],
+                'source_id' => [ false, [ 'source_id' ] ],
+                'target_id' => [ false, [ 'target_id' ] ],
+                'target_type' => [ false, [ 'target_type' ] ],
+            ],
+            'clusterlane_clusters' => [
+                'cluster_key' => [ true, [ 'cluster_key' ] ],
+                'cluster_type' => [ false, [ 'cluster_type' ] ],
+                'health_score' => [ false, [ 'health_score' ] ],
+                'is_active' => [ false, [ 'is_active' ] ],
+            ],
+            'clusterlane_post_metrics' => [
+                'primary_cluster' => [ false, [ 'primary_cluster' ] ],
+                'content_class' => [ false, [ 'content_class' ] ],
+                'overall_score' => [ false, [ 'overall_score' ] ],
+            ],
+            'clusterlane_ai_cache' => [
+                'type_context' => [ true, [ 'cache_type', 'context_key' ] ],
+                'expires_at' => [ false, [ 'expires_at' ] ],
+            ],
+            'clusterlane_cluster_post_class' => [
+                'cluster_post' => [ true, [ 'cluster_key', 'post_id' ] ],
+                'cluster_key' => [ false, [ 'cluster_key' ] ],
+                'post_id' => [ false, [ 'post_id' ] ],
+                'content_class' => [ false, [ 'content_class' ] ],
+            ],
+            'clusterlane_custom_members' => [
+                'cluster_post' => [ true, [ 'cluster_key', 'post_id' ] ],
+                'cluster_key' => [ false, [ 'cluster_key' ] ],
+                'post_id' => [ false, [ 'post_id' ] ],
+            ],
+            'clusterlane_snapshots' => [
+                'snap_date' => [ true, [ 'snap_date' ] ],
+            ],
+            'clusterlane_activity' => [
+                'created_at' => [ false, [ 'created_at' ] ],
+                'action_post' => [ false, [ 'action', 'post_id' ] ],
+                'user_id' => [ false, [ 'user_id' ] ],
+            ],
+        ];
+    }
+
+    /**
+     * Add every mapped index that is currently missing. Idempotent.
+     */
+    private static function reconcile_indexes(): void {
+        global $wpdb;
+
+        // Apply any destructive schema changes (index drops/renames) first,
+        // so reconcile can then re-add the new definitions cleanly.
+        self::pre_reconcile_migrations();
+
+        foreach ( self::index_map() as $table => $indexes ) {
+            $full = $wpdb->prefix . $table;
+            foreach ( $indexes as $name => [ $unique, $columns ] ) {
+                self::add_index_if_missing( $full, $name, $unique, $columns );
+            }
+        }
+    }
+
+    /**
+     * Versioned, one-off destructive migrations that must run before the
+     * idempotent reconcile (e.g. dropping an index whose columns changed
+     * so the new definition can be added). Keep each guarded so it is a
+     * no-op once applied. Left intentionally empty until the next such
+     * change; the historical source_target column change is already past.
+     *
+     * Example, for future reference:
+     *   $links = $wpdb->prefix . 'clusterlane_links';
+     *   self::drop_index_if_exists( $links, 'source_target' ); // reconcile re-adds the new form
+     */
+    private static function pre_reconcile_migrations(): void {
+        // No pending destructive migrations.
+    }
+
+    /** True if $index_name exists on $table (both are full, prefixed names). */
+    private static function index_exists( string $table, string $index_name ): bool {
+        global $wpdb;
+        $found = $wpdb->get_var(
+            $wpdb->prepare(
+                "SELECT COUNT(1) FROM information_schema.STATISTICS
+                  WHERE table_schema = DATABASE()
+                    AND table_name   = %s
+                    AND index_name   = %s",
+                $table,
+                $index_name
+            )
+        );
+        return (int) $found > 0;
+    }
+
+    /**
+     * ALTER TABLE ... ADD [UNIQUE] KEY — but only if the index is absent.
+     * Table, index and column names go in as %i identifiers.
+     *
+     * @param string[] $columns
+     */
+    private static function add_index_if_missing( string $table, string $index_name, bool $unique, array $columns ): void {
+        global $wpdb;
+        if ( ! $columns || self::index_exists( $table, $index_name ) ) return;
+        $args = array_merge( [ $table, $index_name ], array_values( $columns ) );
+        if ( $unique ) {
+            $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD UNIQUE KEY %i (' . implode( ', ', array_fill( 0, count( $columns ), '%i' ) ) . ')', $args ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- built from code-only fragments; every value is a placeholder
+        } else {
+            $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i ADD KEY %i (' . implode( ', ', array_fill( 0, count( $columns ), '%i' ) ) . ')', $args ) ); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- built from code-only fragments; every value is a placeholder
+        }
+    }
+
+    /** DROP INDEX ... — but only if present. For versioned migrations. */
+    private static function drop_index_if_exists( string $table, string $index_name ): void {
+        global $wpdb;
+        if ( ! self::index_exists( $table, $index_name ) ) return;
+        $wpdb->query( $wpdb->prepare( 'ALTER TABLE %i DROP INDEX %i', $table, $index_name ) );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-lang.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-lang.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-lang.php	(revision 3751027)
@@ -0,0 +1,192 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Lang (0.98.168) — which language an article is in, on sites that run
+ * Polylang or WPML. Everything here is a no-op on a one-language site:
+ * active() is false, of() returns '', same() is always true.
+ *
+ * Used so that Clusterlane never mixes languages where it matters:
+ *   - Text Match and Link Suggestions only suggest links between articles in
+ *     the same language (a Swedish text never gets a link to the English
+ *     version of an article with the same title);
+ *   - AI writes link sentences, SEO titles and descriptions in the article's
+ *     own language, and checks clusters in their articles' language;
+ *   - the article lists can be filtered by language, and the CSV exports say it;
+ *   - a slug that exists in several languages resolves to the one in the
+ *     linking article's language.
+ *
+ * Polylang keeps the language as a term in its "language" taxonomy; WPML in
+ * its icl_translations table. Both are read in bulk with one query.
+ */
+final class Lang {
+
+    /** 'polylang', 'wpml' or '' (one language). */
+    public static function plugin(): string {
+        static $p = null;
+        if ( $p !== null ) return $p;
+        $found = '';
+        if ( function_exists( 'pll_get_post_language' ) && function_exists( 'pll_languages_list' ) ) $found = 'polylang';
+        elseif ( defined( 'ICL_SITEPRESS_VERSION' ) && has_filter( 'wpml_active_languages' ) ) $found = 'wpml';
+        // Before all plugins have loaded, "none" may just mean "not loaded yet" — don't remember it.
+        if ( $found !== '' || did_action( 'plugins_loaded' ) ) $p = $found;
+        return $found;
+    }
+
+    /** True when the site has a multilingual plugin with at least two languages. */
+    public static function active(): bool {
+        return self::plugin() !== '' && count( self::languages() ) > 1;
+    }
+
+    /**
+     * The site's languages: code → [ 'name' => native name, 'locale' => sv_SE, 'english' => Swedish ].
+     * @return array<string,array{name:string,locale:string,english:string}>
+     */
+    public static function languages(): array {
+        static $cache = null;
+        if ( $cache !== null ) return $cache;
+        if ( ! did_action( 'init' ) ) return []; // languages are registered on init
+        $out = [];
+        if ( self::plugin() === 'polylang' ) {
+            foreach ( (array) pll_languages_list( [ 'fields' => '' ] ) as $l ) {
+                if ( ! is_object( $l ) || empty( $l->slug ) ) continue;
+                $out[ (string) $l->slug ] = [ 'name' => (string) $l->name, 'locale' => (string) ( $l->locale ?? '' ), 'english' => self::english_name( (string) ( $l->locale ?? $l->slug ), (string) $l->name ) ];
+            }
+        } elseif ( self::plugin() === 'wpml' ) {
+            foreach ( (array) apply_filters( 'wpml_active_languages', null, [ 'skip_missing' => 0 ] ) as $code => $l ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter
+                $name   = (string) ( $l['native_name'] ?? $l['translated_name'] ?? $code );
+                $locale = (string) ( $l['default_locale'] ?? '' );
+                $out[ (string) $code ] = [ 'name' => $name, 'locale' => $locale, 'english' => self::english_name( $locale ?: (string) $code, (string) ( $l['translated_name'] ?? $name ) ) ];
+            }
+        }
+        return $cache = $out;
+    }
+
+    /** The language code of one article ('' when unknown or one-language site). */
+    public static function of( int $post_id ): string {
+        if ( ! $post_id || self::plugin() === '' ) return '';
+        return self::map( [ $post_id ] )[ $post_id ] ?? '';
+    }
+
+    /**
+     * Language codes for many articles in one query.
+     * @param int[] $ids
+     * @return array<int,string> post ID → language code (missing when unknown)
+     */
+    public static function map( array $ids ): array {
+        static $seen = [];
+        $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) );
+        if ( ! $ids || self::plugin() === '' ) return [];
+        $need = array_values( array_filter( $ids, fn( $id ) => ! array_key_exists( $id, $seen ) ) );
+        global $wpdb;
+        foreach ( array_chunk( $need, 1000 ) as $chunk ) {
+            if ( self::plugin() === 'polylang' ) {
+                $rows = $wpdb->get_results( $wpdb->prepare(
+                    "SELECT tr.object_id AS id, t.slug AS lang FROM {$wpdb->term_relationships} tr
+                    INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'language'
+                    INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
+                    WHERE tr.object_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')',
+                    $chunk
+                ) );
+            } else {
+                $rows = $wpdb->get_results( $wpdb->prepare(
+                    "SELECT element_id AS id, language_code AS lang FROM {$wpdb->prefix}icl_translations
+                    WHERE element_type LIKE %s AND element_id IN (" . implode( ',', array_fill( 0, count( $chunk ), '%d' ) ) . ')',
+                    array_merge( [ $wpdb->esc_like( 'post_' ) . '%' ], $chunk )
+                ) );
+            }
+            foreach ( $chunk as $id ) $seen[ $id ] = '';
+            foreach ( (array) $rows as $r ) $seen[ (int) $r->id ] = (string) $r->lang;
+        }
+        $out = [];
+        foreach ( $ids as $id ) if ( ( $seen[ $id ] ?? '' ) !== '' ) $out[ $id ] = $seen[ $id ];
+        return $out;
+    }
+
+    /** True unless both articles have a known language and they differ. */
+    public static function same( int $a, int $b ): bool {
+        if ( ! self::active() ) return true;
+        $m = self::map( [ $a, $b ] );
+        return ! isset( $m[ $a ], $m[ $b ] ) || $m[ $a ] === $m[ $b ];
+    }
+
+    /**
+     * Keep only the IDs in $lang (and those with no known language). '' keeps all.
+     * @param int[] $ids
+     * @return int[]
+     */
+    public static function filter_ids( array $ids, string $lang ): array {
+        if ( $lang === '' || ! self::active() ) return array_values( $ids );
+        $m = self::map( $ids );
+        return array_values( array_filter( $ids, fn( $id ) => ! isset( $m[ (int) $id ] ) || $m[ (int) $id ] === $lang ) );
+    }
+
+    /** SQL condition: the post (alias.ID) is in $lang. '' or one-language site → '1=1'. */
+    public static function sql( string $lang, string $alias = 'p' ): string {
+        global $wpdb;
+        if ( $lang === '' || ! self::active() ) return '1=1';
+        // The posts table alias (or none) goes in as an identifier.
+        if ( self::plugin() === 'polylang' ) {
+            if ( $alias === '' ) {
+                return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} ltr
+                    INNER JOIN {$wpdb->term_taxonomy} ltt ON ltt.term_taxonomy_id = ltr.term_taxonomy_id AND ltt.taxonomy = 'language'
+                    INNER JOIN {$wpdb->terms} lt ON lt.term_id = ltt.term_id AND lt.slug = %s
+                    WHERE ltr.object_id = ID )", $lang );
+            }
+            return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} ltr
+                INNER JOIN {$wpdb->term_taxonomy} ltt ON ltt.term_taxonomy_id = ltr.term_taxonomy_id AND ltt.taxonomy = 'language'
+                INNER JOIN {$wpdb->terms} lt ON lt.term_id = ltt.term_id AND lt.slug = %s
+                WHERE ltr.object_id = %i.ID )", $lang, $alias );
+        }
+        if ( $alias === '' ) {
+            return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->prefix}icl_translations lit
+                WHERE lit.element_id = ID AND lit.element_type = CONCAT( 'post_', post_type ) AND lit.language_code = %s )", $lang );
+        }
+        return $wpdb->prepare( "EXISTS ( SELECT 1 FROM {$wpdb->prefix}icl_translations lit
+            WHERE lit.element_id = %i.ID AND lit.element_type = CONCAT( 'post_', %i.post_type ) AND lit.language_code = %s )", $alias, $alias, $lang );
+    }
+
+    /**
+     * An instruction for an AI prompt: the language of this article, or '' on a
+     * one-language site (the prompt's own rule then applies).
+     */
+    public static function prompt_rule( int $post_id, string $what = 'Write' ): string {
+        $name = self::english( self::of( $post_id ) );
+        return $name !== '' ? "{$what} in {$name} — the language of the article." : '';
+    }
+
+    /** The most common language among these articles ('' when none known). */
+    public static function majority( array $ids ): string {
+        $count = array_count_values( self::map( $ids ) );
+        if ( ! $count ) return '';
+        arsort( $count );
+        return (string) array_key_first( $count );
+    }
+
+    /** English name of a language code, for AI prompts ("Swedish"). */
+    public static function english( string $code ): string {
+        if ( $code === '' ) return '';
+        return self::languages()[ $code ]['english'] ?? $code;
+    }
+
+    /** "sv_SE" → "Swedish"; falls back to the plugin's name. */
+    private static function english_name( string $locale, string $fallback ): string {
+        static $names = null;
+        if ( $names === null ) {
+            $common = [ 'sv' => 'Swedish', 'en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish', 'it' => 'Italian', 'nl' => 'Dutch',
+                        'da' => 'Danish', 'nb' => 'Norwegian', 'nn' => 'Norwegian', 'no' => 'Norwegian', 'fi' => 'Finnish', 'pt' => 'Portuguese', 'pl' => 'Polish',
+                        'ru' => 'Russian', 'ja' => 'Japanese', 'zh' => 'Chinese', 'ko' => 'Korean', 'ar' => 'Arabic', 'tr' => 'Turkish', 'cs' => 'Czech', 'el' => 'Greek' ];
+            $names = $common;
+        }
+        $base = strtolower( (string) preg_replace( '/[_-].*$/', '', $locale ) );
+        return $names[ $base ] ?? $fallback;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-legacy.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-legacy.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-legacy.php	(revision 3751027)
@@ -0,0 +1,245 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery
+// -- One-time move of the plugin's own tables and keys. Every interpolated name comes from the fixed
+//    lists below plus $wpdb->prefix; nothing user-supplied reaches a query.
+
+/**
+ * Legacy — takes over the data of ClusterIQ, the plugin's earlier name (1.3.0).
+ *
+ * ClusterIQ stored everything under cci_ / clusteriq names. On the first
+ * boot after the switch, Clusterlane renames its tables, copies its options,
+ * renames post meta and moves the role capabilities, so a site keeps its
+ * clusters, history, settings (including the encrypted AI key) and access
+ * rules. Runs once per site; on multisite each site migrates the first time
+ * Clusterlane boots there.
+ *
+ * While ClusterIQ itself is still active, nothing is moved and Clusterlane
+ * does not start: a notice asks the user to deactivate ClusterIQ (a button
+ * to WordPress's own Deactivate action). The move happens on the first
+ * request after that. Clusterlane never deactivates a plugin by itself.
+ */
+final class Legacy {
+
+    /** Set when the move is done (or there was nothing to move). */
+    const DONE = 'clusterlane_migrated';
+
+    /** Guards against two requests moving the data at the same time. */
+    const LOCK = 'clusterlane_migrating';
+
+    /** One-time notice for the admin after the move. */
+    const NOTICE = 'clusterlane_migrated_notice';
+
+    /** Plugin files of ClusterIQ (free and Pro, any folder) and its predecessor. */
+    const OLD_FILE = '#(^|/)(clusteriq|content-cluster-intelligence)\.php$#';
+
+    const TABLES = [ 'links', 'clusters', 'post_metrics', 'ai_cache', 'custom_members', 'tms_skipped', 'cluster_post_class', 'snapshots', 'activity' ];
+
+    const OPTIONS = [ 'settings', 'db_version', 'caps_version', 'ai_usage', 'chosen_pillars', 'cluster_sections', 'cluster_groups', 'data_version', 'last_snapshot', 'render_log', 'page_queue', 'hub_relations' ];
+
+    const POST_META = [ 'gap_cluster', 'elementor_backup', 'seo_check', 'seo_check_min', 'seo_original', 'page_stats' ];
+
+    const CAPS = [
+        'manage_clusteriq'    => 'manage_clusterlane',
+        'use_clusteriq'       => 'use_clusterlane',
+        'use_clusteriq_ai'    => 'use_clusterlane_ai',
+        'clusteriq_add_links' => 'clusterlane_add_links',
+        'clusteriq_write_seo' => 'clusterlane_write_seo',
+    ];
+
+    /* ------------------------------------------------------------------
+       Entry points
+    ------------------------------------------------------------------ */
+
+    /**
+     * Called first thing in Plugin::boot(). Cheap once the site is done.
+     *
+     * @return bool false while ClusterIQ itself is still active: Clusterlane
+     *              then waits (it only shows a notice) until the user
+     *              deactivates ClusterIQ — it never switches a plugin off by itself.
+     */
+    public static function maybe_migrate(): bool {
+        add_action( 'admin_notices', [ self::class, 'notice' ] );
+        add_action( 'network_admin_notices', [ self::class, 'notice' ] );
+
+        if ( get_option( self::DONE ) ) return true;
+
+        if ( self::old_active() ) return false; // never move tables out from under a running ClusterIQ
+
+        if ( ! self::has_old_data() ) {
+            update_option( self::DONE, [ 'at' => time(), 'moved' => false ], true );
+            return true;
+        }
+
+        if ( ! add_option( self::LOCK, time(), '', false ) ) {
+            if ( time() - (int) get_option( self::LOCK, 0 ) < 5 * MINUTE_IN_SECONDS ) return true;
+            update_option( self::LOCK, time(), false ); // a run that died: take over
+        }
+
+        self::migrate_site();
+        if ( is_multisite() ) self::migrate_network();
+
+        delete_option( self::LOCK );
+        update_option( self::DONE, [ 'at' => time(), 'moved' => true ], true );
+        set_transient( self::NOTICE, 1, DAY_IN_SECONDS );
+        wp_cache_flush(); // post meta and roles were changed directly
+        return true;
+    }
+
+    /**
+     * While ClusterIQ is active: what to do, with a button for each active
+     * copy that leads to WordPress's own Deactivate action (the user decides).
+     * After the move: one confirmation.
+     */
+    public static function notice(): void {
+        if ( ! current_user_can( 'activate_plugins' ) ) return;
+
+        $old = array_values( array_filter( self::active_plugins(), static fn( $p ) => (bool) preg_match( self::OLD_FILE, $p ) ) );
+        if ( $old && ! get_option( self::DONE ) ) {
+            echo '<div class="notice notice-warning"><p><strong>Clusterlane:</strong> '
+                . esc_html__( 'ClusterIQ is now called Clusterlane. Deactivate ClusterIQ to move your clusters, history, settings and access rules over — Clusterlane waits until then, so the two never run at the same time.', 'clusterlane' )
+                . '</p><p>';
+            foreach ( $old as $plugin ) {
+                $network = is_multisite() && array_key_exists( $plugin, (array) get_site_option( 'active_sitewide_plugins', [] ) );
+                $base    = $network ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' );
+                $url     = wp_nonce_url( add_query_arg( [ 'action' => 'deactivate', 'plugin' => rawurlencode( $plugin ) ], $base ), 'deactivate-plugin_' . $plugin );
+                echo '<a class="button button-primary" href="' . esc_url( $url ) . '">'
+                    . esc_html(
+                        /* translators: %s: plugin folder and file, e.g. clusteriq/clusteriq.php */
+                        sprintf( __( 'Deactivate ClusterIQ (%s)', 'clusterlane' ), $plugin )
+                    )
+                    . '</a> ';
+            }
+            echo '</p></div>';
+            return;
+        }
+        if ( get_transient( self::NOTICE ) ) {
+            delete_transient( self::NOTICE );
+            echo '<div class="notice notice-success is-dismissible"><p><strong>Clusterlane:</strong> '
+                . esc_html__( 'Your ClusterIQ data has moved over: clusters, history, settings and access rules. The old ClusterIQ plugin can be deleted — deleting it no longer touches this data.', 'clusterlane' )
+                . '</p></div>';
+        }
+    }
+
+    /* ------------------------------------------------------------------
+       The move
+    ------------------------------------------------------------------ */
+
+    private static function migrate_site(): void {
+        global $wpdb;
+        $had_mapping = false !== get_option( 'cci_caps_version', false ); // ClusterIQ had applied its role rights
+
+        // 1. Tables: rename, so nothing is copied. Clusterlane's activation
+        //    may already have created empty tables — those make way.
+        foreach ( self::TABLES as $bare ) {
+            $old = $wpdb->prefix . 'cci_' . $bare;
+            $new = $wpdb->prefix . 'clusterlane_' . $bare;
+            if ( ! self::table_exists( $old ) ) continue;
+            if ( self::table_exists( $new ) ) {
+                if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $new ) ) > 0 ) continue; // already in use: keep it
+                $wpdb->query( $wpdb->prepare( 'DROP TABLE %i', $new ) );
+            }
+            $wpdb->query( $wpdb->prepare( 'RENAME TABLE %i TO %i', $old, $new ) );
+        }
+
+        // 2. Options: ClusterIQ's values win over the defaults written on activation.
+        foreach ( self::OPTIONS as $bare ) {
+            $value = get_option( 'cci_' . $bare, null );
+            if ( null === $value ) continue;
+            update_option( 'clusterlane_' . $bare, $value, false );
+            delete_option( 'cci_' . $bare );
+        }
+        delete_option( 'cci_page_lock' );
+
+        // 3. Post meta.
+        foreach ( self::POST_META as $bare ) {
+            $wpdb->update( $wpdb->postmeta, [ 'meta_key' => '_clusterlane_' . $bare ], [ 'meta_key' => '_cci_' . $bare ] );
+        }
+
+        // 4. Capabilities: the new ones follow exactly what each role had, so
+        //    a right an admin took away stays away.
+        foreach ( wp_roles()->role_objects as $role ) {
+            foreach ( self::CAPS as $old => $new ) {
+                if ( isset( $role->capabilities[ $old ] ) ) {
+                    $role->add_cap( $new, (bool) $role->capabilities[ $old ] );
+                    $role->remove_cap( $old );
+                } elseif ( $had_mapping && $role->has_cap( $new ) ) {
+                    $role->remove_cap( $new );
+                }
+            }
+        }
+        // Rights given to single users.
+        $users = get_users( [
+            'blog_id'      => get_current_blog_id(),
+            'meta_key'     => $wpdb->get_blog_prefix() . 'capabilities',
+            'meta_value'   => 'clusteriq',
+            'meta_compare' => 'LIKE',
+            'fields'       => 'all',
+        ] );
+        foreach ( $users as $user ) {
+            foreach ( self::CAPS as $old => $new ) {
+                if ( isset( $user->caps[ $old ] ) ) {
+                    $user->add_cap( $new, (bool) $user->caps[ $old ] );
+                    $user->remove_cap( $old );
+                }
+            }
+        }
+
+        // 5. Short-lived data (undo snapshots, locks) and ClusterIQ's scheduled events.
+        $wpdb->query(
+            "DELETE FROM {$wpdb->options}
+             WHERE option_name LIKE '\\_transient\\_cci\\_%' OR option_name LIKE '\\_transient\\_timeout\\_cci\\_%'"
+        );
+        wp_clear_scheduled_hook( 'cci_daily_snapshot' );
+        wp_clear_scheduled_hook( 'cci_read_pages' );
+
+        // Pages still waiting to be read: pick up where ClusterIQ left off.
+        $queue = get_option( 'clusterlane_page_queue' );
+        if ( ! empty( $queue ) && ! wp_next_scheduled( 'clusterlane_read_pages' ) ) {
+            wp_schedule_single_event( time() + 60, 'clusterlane_read_pages' );
+        }
+    }
+
+    /** The network-wide AI key and policy (multisite). */
+    private static function migrate_network(): void {
+        $value = get_site_option( 'cci_network_ai', null );
+        if ( null === $value ) return;
+        if ( null === get_site_option( 'clusterlane_network_ai', null ) ) {
+            update_site_option( 'clusterlane_network_ai', $value );
+        }
+        delete_site_option( 'cci_network_ai' );
+    }
+
+    /* ------------------------------------------------------------------
+       Helpers
+    ------------------------------------------------------------------ */
+
+    private static function has_old_data(): bool {
+        global $wpdb;
+        if ( false !== get_option( 'cci_settings', false ) || false !== get_option( 'cci_db_version', false ) ) return true;
+        if ( is_multisite() && null !== get_site_option( 'cci_network_ai', null ) ) return true;
+        return self::table_exists( $wpdb->prefix . 'cci_clusters' );
+    }
+
+    private static function table_exists( string $table ): bool {
+        global $wpdb;
+        return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table;
+    }
+
+    private static function old_active(): bool {
+        foreach ( self::active_plugins() as $plugin ) {
+            if ( preg_match( self::OLD_FILE, $plugin ) ) return true;
+        }
+        return false;
+    }
+
+    /** @return string[] */
+    private static function active_plugins(): array {
+        $active = (array) get_option( 'active_plugins', [] );
+        if ( is_multisite() ) $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) );
+        return array_map( 'strval', $active );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-link-graph.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-link-graph.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-link-graph.php	(revision 3751027)
@@ -0,0 +1,430 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * LinkGraph
+ *
+ * Builds and queries the internal link graph stored in {prefix}clusterlane_links.
+ */
+class LinkGraph {
+
+    /**
+     * Extract all internal <a href> links from a post's content.
+     * Returns each link keyed by "type:id" (e.g. "post:42", "term:7") so
+     * a post-link and a term-link can never collide, since post IDs and
+     * term IDs are independent numeric sequences in WordPress.
+     *
+     * @return array<string, array{type: string, id: int, anchor: string}>
+     */
+    public function extract_links( int $post_id ): array {
+        $post = get_post( $post_id );
+        if ( ! $post ) return [];
+
+        preg_match_all(
+            '/<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is',
+            $post->post_content,
+            $matches,
+            PREG_SET_ORDER
+        );
+
+        return $this->resolve_links( array_map( fn( $m ) => [ $m[1], wp_strip_all_tags( $m[2] ) ], $matches ), $post_id );
+    }
+
+    /**
+     * Internal links among [url, anchor] pairs — from the article text, or from
+     * its published page (PageReader, 0.98.177) — keyed "type:id" like extract_links().
+     *
+     * @param  array<int, array{0: string, 1: string}> $pairs
+     * @return array<string, array{type: string, id: int, anchor: string}>
+     */
+    public function resolve_links( array $pairs, int $post_id ): array {
+        $links = [];
+        foreach ( $pairs as [ $url, $anchor ] ) {
+            $url    = (string) $url;
+            $anchor = (string) $anchor;
+
+            // Only internal URLs — on subdirectory multisite this also
+            // excludes links to other sites on the same domain.
+            if ( ! \Clusterlane\Core\Multisite::is_internal_url( $url ) ) continue;
+
+            $target = $this->resolve_target( \Clusterlane\Core\Multisite::normalize_url( $url ), $post_id );
+            if ( ! $target ) continue;
+            if ( $target['type'] === 'post' && $target['id'] === $post_id ) continue;
+
+            $key = $target['type'] . ':' . $target['id'];
+            $links[ $key ] = [
+                'type'   => $target['type'],
+                'id'     => $target['id'],
+                'anchor' => substr( $anchor, 0, 255 ),
+            ];
+        }
+
+        return $links;
+    }
+
+    /**
+     * Resolve an internal URL to either a post or a taxonomy term
+     * (category/tag archive page). Posts are tried first since they're
+     * the overwhelmingly common case and url_to_postid() is authoritative;
+     * term archives are only checked once no post matches.
+     *
+     * @return array{type: string, id: int}|null
+     */
+    private function resolve_target( string $url, int $source_id = 0 ): ?array {
+        // Keyed by blog ID: static vars survive switch_to_blog(), so a
+        // single request touching several sites (WP-CLI loops, network
+        // tools) must not reuse another site's slug map.
+        static $caches = [];
+        $blog_id = get_current_blog_id();
+        if ( ! isset( $caches[ $blog_id ] ) ) {
+            $caches[ $blog_id ] = [ $this->build_slug_cache(), $this->build_term_slug_cache() ];
+        }
+        [ $post_cache, $term_cache ] = $caches[ $blog_id ];
+
+        // Plain permalinks (?p=123, ?page_id=, ?cat=, ?tag=) carry the target
+        // in the query string — resolve them before it is stripped below.
+        // Without this, sites using plain permalinks got an empty link graph.
+        if ( strpos( $url, '?' ) !== false ) {
+            $id = url_to_postid( \Clusterlane\Core\Multisite::absolutize( $url ) );
+            if ( $id ) return [ 'type' => 'post', 'id' => $id ];
+
+            $query = [];
+            parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+            if ( ! empty( $query['cat'] ) && is_numeric( $query['cat'] ) ) {
+                $term = get_term( (int) $query['cat'], 'category' );
+                if ( $term && ! is_wp_error( $term ) ) return [ 'type' => 'term', 'id' => (int) $term->term_id ];
+            }
+            if ( ! empty( $query['tag'] ) && is_string( $query['tag'] ) ) {
+                $term = get_term_by( 'slug', $query['tag'], 'post_tag' );
+                if ( $term ) return [ 'type' => 'term', 'id' => (int) $term->term_id ];
+            }
+            $cpt = self::query_var_id( $query );
+            if ( $cpt ) return [ 'type' => 'post', 'id' => $cpt ];
+        }
+
+        // Strip query string and fragment.
+        $clean = strtok( $url, '?#' );
+        $clean = rtrim( $clean, '/' );
+
+        // Strip .htm / .html extension.
+        $clean = preg_replace( '/\.html?$/i', '', $clean );
+
+        // Try native url_to_postid first (handles edge cases).
+        $id = url_to_postid( $clean );
+        if ( $id ) return [ 'type' => 'post', 'id' => $id ];
+
+        $path = wp_parse_url( $clean, PHP_URL_PATH );
+        if ( ! $path ) return null;
+
+        $parts = explode( '/', trim( $path, '/' ) );
+        $slug  = end( $parts );
+
+        if ( isset( $post_cache[ $slug ] ) ) {
+            // Multilingual sites (0.98.168): the same slug can exist in several languages —
+            // take the one in the linking article's language.
+            $in_lang = $source_id ? self::slug_in_lang( (string) $slug, Lang::of( $source_id ) ) : 0;
+            return [ 'type' => 'post', 'id' => $in_lang ?: $post_cache[ $slug ] ];
+        }
+        if ( isset( $term_cache[ $slug ] ) ) {
+            return [ 'type' => 'term', 'id' => $term_cache[ $slug ] ];
+        }
+        // An article's old slug: WordPress redirects it to the article, so the link counts (0.98.167).
+        $old = self::old_slug_id( (string) $slug );
+        if ( $old ) return [ 'type' => 'post', 'id' => $old ];
+
+        return null;
+    }
+
+    /**
+     * Custom post types with plain permalinks: ?product=<slug> (WooCommerce) or any
+     * ?<query_var>=<slug> — url_to_postid() only knows ?p= and ?page_id= (0.98.172).
+     * @param array|string $query parsed query array, or a URL
+     */
+    public static function query_var_id( $query ): int {
+        if ( is_string( $query ) ) { $q = []; parse_str( (string) wp_parse_url( $query, PHP_URL_QUERY ), $q ); $query = $q; }
+        foreach ( Scope::post_types() as $pt ) {
+            $obj = get_post_type_object( $pt );
+            $qv  = $obj && is_string( $obj->query_var ) && $obj->query_var !== '' ? $obj->query_var : '';
+            if ( $qv === '' || empty( $query[ $qv ] ) || ! is_string( $query[ $qv ] ) ) continue;
+            $hit = get_page_by_path( sanitize_title( $query[ $qv ] ), OBJECT, $pt );
+            if ( $hit ) return (int) $hit->ID;
+        }
+        return 0;
+    }
+
+    /** The article with this slug in $lang (0 when not multilingual, unknown or none). */
+    private static function slug_in_lang( string $slug, string $lang ): int {
+        if ( $lang === '' || ! Lang::active() ) return 0;
+        static $cache = [];
+        $k = $lang . '|' . $slug;
+        if ( ! array_key_exists( $k, $cache ) ) {
+            global $wpdb;
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare(
+                "SELECT ID FROM {$wpdb->posts} WHERE post_name = %s AND " . Scope::sql() . ' AND ' . Scope::type_sql( '', true ), $slug // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Scope fragments
+            ) ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+            $cache[ $k ] = count( $ids ) > 1 ? ( Lang::filter_ids( $ids, $lang )[0] ?? 0 ) : 0;
+        }
+        return $cache[ $k ];
+    }
+
+    /**
+     * A published article by its slug, as the link graph resolves it: the current
+     * slug first, then an old one (0.98.167 — shared with Text Match, so both agree
+     * on what a link points at).
+     */
+    public static function slug_id( string $slug ): int {
+        static $caches = [];
+        $blog_id = get_current_blog_id();
+        if ( ! isset( $caches[ $blog_id ] ) ) $caches[ $blog_id ] = ( new self() )->build_slug_cache();
+        return (int) ( $caches[ $blog_id ][ $slug ] ?? self::old_slug_id( $slug ) );
+    }
+
+    /**
+     * The published article that used to have this slug (WordPress keeps old
+     * slugs in _wp_old_slug and redirects to the article). 0 when none — or when
+     * several articles once had it, so a link is never pinned on a guess.
+     */
+    public static function old_slug_id( string $slug ): int {
+        static $caches = [];
+        $blog_id = get_current_blog_id();
+        if ( ! isset( $caches[ $blog_id ] ) ) {
+            global $wpdb;
+            $map = [];
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            foreach ( (array) $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+                "SELECT pm.meta_value AS slug, pm.post_id AS id FROM {$wpdb->postmeta} pm
+                 INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = pm.post_id
+                 WHERE pm.meta_key = %s AND " . \Clusterlane\Core\Scope::sql() . ' AND ' . \Clusterlane\Core\Scope::type_sql( '', true ),
+                '_wp_old_slug'
+            ), ARRAY_A ) as $r ) {
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+                $s = (string) $r['slug'];
+                $map[ $s ] = isset( $map[ $s ] ) && $map[ $s ] !== (int) $r['id'] ? 0 : (int) $r['id'];
+            }
+            $caches[ $blog_id ] = $map;
+        }
+        return (int) ( $caches[ $blog_id ][ $slug ] ?? 0 );
+    }
+
+    /**
+     * Build a slug→ID map for all published posts.
+     * Called once per request and cached statically.
+     *
+     * @return array<string, int>
+     */
+    private function build_slug_cache(): array {
+        global $wpdb;
+
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            'SELECT ID, post_name
+             FROM %i
+             WHERE ' . \Clusterlane\Core\Scope::sql() . '
+             AND ' . \Clusterlane\Core\Scope::type_sql( '', true ), // the scanned post types (+ pages, often linked)
+            $wpdb->posts
+        ), ARRAY_A );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+
+        $cache = [];
+        foreach ( $rows as $row ) {
+            $cache[ $row['post_name'] ] = (int) $row['ID'];
+        }
+        return $cache;
+    }
+
+    /**
+     * Build a slug→term_id map, but only for taxonomies that are actually
+     * used as cluster types (clusterlane_clusters.cluster_type) — those are the
+     * only term archives that matter as a potential "hub" node. Scanning
+     * every taxonomy on the site would be wasted work and risks matching
+     * an unrelated term archive that happens to share a slug.
+     *
+     * @return array<string, int>
+     */
+    private function build_term_slug_cache(): array {
+        global $wpdb;
+
+        $taxonomies = $wpdb->get_col(
+            "SELECT DISTINCT cluster_type FROM {$wpdb->prefix}clusterlane_clusters
+             WHERE cluster_type != 'custom'"
+        );
+
+        $cache = [];
+        foreach ( $taxonomies as $taxonomy ) {
+            if ( ! taxonomy_exists( $taxonomy ) ) continue;
+
+            $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false ] );
+            if ( is_wp_error( $terms ) ) continue;
+
+            foreach ( $terms as $term ) {
+                $link = get_term_link( $term );
+                if ( is_wp_error( $link ) ) continue;
+
+                $path = wp_parse_url( $link, PHP_URL_PATH );
+                if ( ! $path ) continue;
+
+                $parts = explode( '/', trim( $path, '/' ) );
+                $slug  = end( $parts );
+
+                $cache[ $slug ] = (int) $term->term_id;
+            }
+        }
+
+        return $cache;
+    }
+
+    /**
+     * Persist links for a post to the DB.
+     * Replaces all existing links for this source.
+     *
+     * @param array<string, array{type: string, id: int, anchor: string}> $links
+     *        As returned by extract_links().
+     */
+    public function save_links( int $source_id, array $links, ?array $template = null ): void {
+        global $wpdb;
+        $table = $wpdb->prefix . 'clusterlane_links';
+        $now   = current_time( 'mysql', true ); // UTC, consistent with clusterlane_post_metrics.last_scanned
+
+        // Links the theme adds on the published page (PageReader, 0.98.177). null =
+        // keep this article's stored ones: an edit of the text (Apply, Undo, Text
+        // Match) doesn't change what the templates show.
+        if ( $template === null ) $template = $this->template_links_of( $source_id );
+
+        // Remove stale records.
+        $wpdb->delete( $table, [ 'source_id' => $source_id ], [ '%d' ] );
+
+        /**
+         * Links the article shows that are not in its text (Pro: the pillar
+         * link box, 0.98.134). Same shape as $links; stored as link_type
+         * 'auto' so metrics count them and tools can tell them apart.
+         */
+        $auto = (array) apply_filters( 'clusterlane_auto_links', [], $source_id, $links );
+        foreach ( $auto as $key => $link ) {
+            if ( isset( $links[ $key ] ) || empty( $link['id'] ) ) continue;
+            $wpdb->replace( $table, [
+                'source_id'   => $source_id,
+                'source_type' => 'post',
+                'target_id'   => (int) $link['id'],
+                'target_type' => $link['type'] ?? 'post',
+                'anchor_text' => substr( (string) ( $link['anchor'] ?? '' ), 0, 255 ),
+                'link_type'   => 'auto',
+                'scanned_at'  => $now,
+            ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] );
+        }
+
+        foreach ( $links as $link ) {
+            $wpdb->replace( $table, [
+                'source_id'   => $source_id,
+                'source_type' => 'post',
+                'target_id'   => $link['id'],
+                'target_type' => $link['type'],
+                'anchor_text' => $link['anchor'],
+                'link_type'   => 'inline',
+                'scanned_at'  => $now,
+            ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] );
+        }
+
+        // Page links that are neither in the text nor the pillar box.
+        foreach ( $template as $key => $link ) {
+            if ( isset( $links[ $key ] ) || isset( $auto[ $key ] ) || empty( $link['id'] ) ) continue;
+            if ( ( $link['type'] ?? 'post' ) === 'post' && (int) $link['id'] === $source_id ) continue;
+            $wpdb->replace( $table, [
+                'source_id'   => $source_id,
+                'source_type' => 'post',
+                'target_id'   => (int) $link['id'],
+                'target_type' => $link['type'] ?? 'post',
+                'anchor_text' => substr( (string) ( $link['anchor'] ?? '' ), 0, 255 ),
+                'link_type'   => 'template',
+                'scanned_at'  => $now,
+            ], [ '%d', '%s', '%d', '%s', '%s', '%s', '%s' ] );
+        }
+    }
+
+    /**
+     * The template links stored for an article (PageReader), keyed like extract_links().
+     * @return array<string, array{type: string, id: int, anchor: string}>
+     */
+    public function template_links_of( int $source_id ): array {
+        global $wpdb;
+        $out = [];
+        foreach ( (array) $wpdb->get_results( $wpdb->prepare(
+            "SELECT target_id, target_type, anchor_text FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND link_type = 'template'",
+            $source_id
+        ) ) as $r ) {
+            $out[ $r->target_type . ':' . (int) $r->target_id ] = [ 'type' => (string) $r->target_type, 'id' => (int) $r->target_id, 'anchor' => (string) $r->anchor_text ];
+        }
+        return $out;
+    }
+
+    /**
+     * Get all source IDs that link TO $target_id.
+     * $type distinguishes a post target from a term (category hub) target,
+     * since the two ID spaces can otherwise collide numerically.
+     * @return int[]
+     */
+    public function get_inbound( int $target_id, string $type = 'post' ): array {
+        global $wpdb;
+        return array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
+            "SELECT source_id FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = %s",
+            $target_id, $type
+        ) ) );
+    }
+
+    /** Get all post IDs that $source_id links TO. @return int[] */
+    public function get_outbound( int $source_id ): array {
+        global $wpdb;
+        return array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
+            "SELECT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post'",
+            $source_id
+        ) ) );
+    }
+
+    /**
+     * Count inbound links for a target. $type defaults to 'post' so every
+     * existing caller (post inbound counts) keeps working unchanged; pass
+     * 'term' to count links into a category/tag archive hub instead.
+     */
+    public function count_inbound( int $target_id, string $type = 'post' ): int {
+        global $wpdb;
+        return (int) $wpdb->get_var( $wpdb->prepare(
+            "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = %s",
+            $target_id, $type
+        ) );
+    }
+
+    /** Count outbound links for a post. */
+    public function count_outbound( int $source_id ): int {
+        global $wpdb;
+        return (int) $wpdb->get_var( $wpdb->prepare(
+            "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d",
+            $source_id
+        ) );
+    }
+
+    /**
+     * Get all unique post IDs that appear in the link graph
+     * (either as source or target).
+     * @return int[]
+     */
+    public function get_all_linked_ids(): array {
+        global $wpdb;
+        $table = $wpdb->prefix . 'clusterlane_links';
+        $ids = $wpdb->get_col( $wpdb->prepare(
+            "SELECT DISTINCT source_id FROM %i WHERE source_type = 'post'
+             UNION
+             SELECT DISTINCT target_id FROM %i WHERE target_type = 'post'",
+            $table, $table
+        ) );
+        return array_map( 'intval', $ids );
+    }
+}
+
Index: /clusterlane/tags/1.3.2/intelligence/core/class-multisite.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-multisite.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-multisite.php	(revision 3751027)
@@ -0,0 +1,219 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Multisite
+ *
+ * Network lifecycle + URL scoping for WordPress multisite.
+ *
+ * Lifecycle
+ *   - Network activation installs tables + capabilities on existing sites
+ *     (up to NETWORK_ACTIVATE_LIMIT immediately; the rest self-install on
+ *     their first request via Installer::maybe_upgrade() /
+ *     Capabilities::maybe_install(), which run on every boot).
+ *   - New sites get tables + caps as soon as they're created, if Clusterlane
+ *     is network-active.
+ *   - Deleting a site drops that site's Clusterlane tables.
+ *
+ * URL scoping
+ *   On a subdirectory network every site shares one host, so
+ *   https://example.com/site-b/post looked "internal" from the main site
+ *   (it starts with home_url()) and the slug fallback could map it onto an
+ *   unrelated local post with the same slug. is_internal_url() excludes
+ *   URLs that belong to another site on the same domain.
+ *
+ * On single-site installs every method behaves exactly as before.
+ */
+final class Multisite {
+
+    /** Sites installed synchronously on network activation. */
+    const NETWORK_ACTIVATE_LIMIT = 100;
+
+    /** Table names without prefix — must match Installer / uninstall.php. */
+    const TABLES = [
+        'clusterlane_links', 'clusterlane_clusters', 'clusterlane_post_metrics', 'clusterlane_ai_cache',
+        'clusterlane_custom_members', 'clusterlane_tms_skipped', 'clusterlane_cluster_post_class',
+        'clusterlane_snapshots', 'clusterlane_activity',
+    ];
+
+    /* ------------------------------------------------------------------
+       Lifecycle
+    ------------------------------------------------------------------ */
+
+    public static function register_hooks(): void {
+        if ( ! is_multisite() ) return;
+        // Priority 900: after core has created the site's tables and roles.
+        add_action( 'wp_initialize_site', [ self::class, 'on_new_site' ], 900 );
+        add_filter( 'wpmu_drop_tables',   [ self::class, 'drop_tables' ], 10, 2 );
+    }
+
+    public static function is_network_active(): bool {
+        if ( ! is_multisite() ) return false;
+        $plugins = (array) get_site_option( 'active_sitewide_plugins', [] );
+        return isset( $plugins[ plugin_basename( CLUSTERLANE_FILE ) ] );
+    }
+
+    /** Activation hook handler — $network_wide comes from WordPress. */
+    public static function activate( bool $network_wide ): void {
+        require_once __DIR__ . '/class-installer.php';
+
+        if ( ! is_multisite() || ! $network_wide ) {
+            Installer::activate();
+            return;
+        }
+
+        $site_ids = get_sites( [
+            'fields'   => 'ids',
+            'number'   => self::NETWORK_ACTIVATE_LIMIT,
+            'archived' => 0,
+            'deleted'  => 0,
+            'spam'     => 0,
+        ] );
+        foreach ( $site_ids as $site_id ) {
+            switch_to_blog( (int) $site_id );
+            Installer::activate();
+            restore_current_blog();
+        }
+    }
+
+    /**
+     * Deactivation hook handler. Network deactivation used to unschedule the
+     * daily snapshot on the main site only — the other sites kept the event
+     * until uninstall (0.98.175). Data is kept either way.
+     */
+    public static function deactivate( bool $network_wide ): void {
+        require_once __DIR__ . '/class-installer.php';
+
+        if ( ! is_multisite() || ! $network_wide ) {
+            Installer::deactivate();
+            return;
+        }
+
+        foreach ( get_sites( [ 'fields' => 'ids', 'number' => 0 ] ) as $site_id ) {
+            switch_to_blog( (int) $site_id );
+            Installer::deactivate();
+            restore_current_blog();
+        }
+    }
+
+    public static function on_new_site( \WP_Site $site ): void {
+        if ( ! self::is_network_active() ) return;
+        require_once __DIR__ . '/class-installer.php';
+        switch_to_blog( (int) $site->blog_id );
+        Installer::activate();
+        restore_current_blog();
+    }
+
+    /**
+     * @param string[] $tables
+     * @return string[]
+     */
+    public static function drop_tables( array $tables, int $site_id ): array {
+        global $wpdb;
+        $prefix = $wpdb->get_blog_prefix( $site_id );
+        foreach ( self::TABLES as $bare ) {
+            $tables[] = $prefix . $bare;
+        }
+        return $tables;
+    }
+
+    /* ------------------------------------------------------------------
+       URL scoping
+    ------------------------------------------------------------------ */
+
+    /**
+     * True if $url points at the current site.
+     * Single-site / subdomain networks: unchanged behaviour (starts with
+     * home_url(), or is root-relative).
+     */
+    public static function is_internal_url( string $url ): bool {
+        $url      = self::normalize_url( $url );
+        if ( strpos( $url, '//' ) === 0 ) return false; // protocol-relative to another host (ours was rewritten above)
+        $internal = strpos( $url, home_url() ) === 0 || strpos( $url, '/' ) === 0;
+        if ( ! $internal ) return false;
+        if ( ! is_multisite() || is_subdomain_install() ) return true;
+
+        $path = strpos( $url, '/' ) === 0 ? $url : (string) wp_parse_url( $url, PHP_URL_PATH );
+        $path = trailingslashit( '/' . ltrim( (string) strtok( $path, '?#' ), '/' ) );
+        $own  = self::current_site_path();
+
+        // Root-relative link on a sub-site that doesn't include our own path
+        // points at the main site (or another sub-site), not at us.
+        if ( $own !== '/' && strpos( $path, $own ) !== 0 ) return false;
+
+        // Deeper path owned by another site on this domain (e.g. /site-b/).
+        foreach ( self::other_site_paths() as $other ) {
+            if ( strlen( $other ) > strlen( $own ) && strpos( $path, $other ) === 0 ) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * A link to this site written another way — http instead of https, with or
+     * without "www.", protocol-relative "//host/…", host in capitals — rewritten
+     * to home_url()'s own scheme and host, so it counts as the internal link it is
+     * (0.98.167; such links used to be skipped, making their targets look unlinked).
+     * Links to other hosts come back unchanged.
+     */
+    public static function normalize_url( string $url ): string {
+        $url = trim( $url );
+        if ( ! preg_match( '#^(?:https?:)?//([^/?\#:]+)(:\d+)?(.*)$#i', $url, $m ) ) return $url;
+        static $home = null;
+        if ( $home === null ) {
+            $h    = wp_parse_url( home_url() );
+            $home = [
+                'host' => strtolower( (string) ( $h['host'] ?? '' ) ),
+                'base' => ( $h['scheme'] ?? 'https' ) . '://' . ( $h['host'] ?? '' ) . ( isset( $h['port'] ) ? ':' . $h['port'] : '' ),
+            ];
+        }
+        $bare = fn( string $host ) => preg_replace( '/^www\./', '', strtolower( $host ) );
+        if ( $home['host'] === '' || $bare( $m[1] ) !== $bare( $home['host'] ) ) return $url;
+        return $home['base'] . $m[3];
+    }
+
+    /**
+     * Root-relative "/foo" → "https://host/foo". Root-relative URLs are
+     * relative to the domain, not to home_url() — prefixing home_url()
+     * doubled the path on sub-sites and subdirectory installs.
+     */
+    public static function absolutize( string $url ): string {
+        if ( strpos( $url, '//' ) === 0 ) return set_url_scheme( $url );
+        if ( strpos( $url, '/' ) !== 0 ) return $url;
+        $parts = wp_parse_url( home_url() );
+        $base  = ( $parts['scheme'] ?? 'https' ) . '://' . ( $parts['host'] ?? '' )
+               . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );
+        return $base . $url;
+    }
+
+    private static function current_site_path(): string {
+        $details = get_site( get_current_blog_id() );
+        return $details ? trailingslashit( $details->path ) : '/';
+    }
+
+    /** @return string[] paths of the other sites on the current domain */
+    private static function other_site_paths(): array {
+        static $cache = [];
+        $blog_id = get_current_blog_id();
+        if ( isset( $cache[ $blog_id ] ) ) return $cache[ $blog_id ];
+
+        $current = get_site( $blog_id );
+        $paths   = [];
+        if ( $current ) {
+            $sites = get_sites( [
+                'domain'         => $current->domain,
+                'site__not_in'   => [ $blog_id ],
+                'number'         => 0,
+                'fields'         => '',
+                'update_site_meta_cache' => false,
+            ] );
+            foreach ( $sites as $site ) {
+                $paths[] = trailingslashit( $site->path );
+            }
+        }
+        return $cache[ $blog_id ] = $paths;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-page-reader.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-page-reader.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-page-reader.php	(revision 3751027)
@@ -0,0 +1,520 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * PageReader (0.98.177)
+ *
+ * Reads an article's published page the way a visitor — and a search engine —
+ * gets it, for the post types ticked under Settings → Content → "Read the
+ * published pages". Many themes add links outside the article text: lists of
+ * the places in an area, cards, "more in this category", related articles.
+ * Templates build them from custom fields or queries, so they are never in
+ * post_content, and without this such pages look like orphans.
+ *
+ * Only the page's main content counts (<main>, role="main", #main …). Menus,
+ * breadcrumbs, sidebars and the footer link to everything from every page and
+ * would drown the signal, so they are left out. Links inside the main content
+ * that are not in the article text are stored as link_type 'template'.
+ *
+ * A page that can't be read (a firewall, a login wall, a timeout) falls back
+ * to the article text alone; the scan reports how many pages failed and why.
+ *
+ * 0.98.178 — without waiting:
+ * - Settings → Content → "Use cached pages" reads the page as the page cache
+ *   serves it (no throwaway query argument). An answer that doesn't show the
+ *   article is fetched again, fresh.
+ * - A scan from the Scan button reads the text only and queues the pages
+ *   (defer()); run_queue() reads them in slices of ~20 s — from the open
+ *   Clusterlane screen (POST clusterlane/v1/scan/pages) or WP-Cron — and updates the
+ *   metrics when the last page is read. Until then the pages' links, words,
+ *   subheadings and images from the previous reading are kept.
+ *
+ * 1.3.2 — every page is fetched through the WordPress HTTP API (wp_remote_get),
+ * one after another, so the site's proxy, transport and HTTP filters always
+ * apply. (Earlier versions fetched a few pages side by side with the Requests
+ * library directly, which bypasses those settings.)
+ */
+final class PageReader {
+
+    /** Seconds allowed per page. */
+    const TIMEOUT = 12;
+
+    /** Option: pages waiting to be read after a scan (0.98.178). */
+    const QUEUE_OPTION = 'clusterlane_page_queue';
+
+    /** Option: held while a slice of the queue is being read. */
+    const LOCK_OPTION = 'clusterlane_page_lock';
+
+    /** WP-Cron hook that reads the queue when no Clusterlane screen is open. */
+    const CRON_HOOK = 'clusterlane_read_pages';
+
+    /** Post meta: words, subheadings and images on the page when last read. */
+    const STATS_META = '_clusterlane_page_stats';
+
+    /** Seconds one slice of the queue may take. */
+    const SLICE = 20;
+
+    /** Option: what the latest scan could read — ok/failed counts, reasons, an example. */
+    const LOG_OPTION = 'clusterlane_render_log';
+
+    private const LOWER = 'abcdefghijklmnopqrstuvwxyz';
+    private const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+    /** Counts for the batch being scanned; written to LOG_OPTION by flush_log(). */
+    private static array $batch = [ 'ok' => 0, 'failed' => 0, 'reasons' => [], 'example' => null ];
+
+    /** Scan the text now, read the page later (the Scan button). */
+    private static bool $defer = false;
+
+    /** Post IDs queued by this request; written by flush_queue(). */
+    private static array $queued = [];
+
+    private static ?Plugin $plugin = null;
+
+    public static function register_hooks( Plugin $plugin ): void {
+        self::$plugin = $plugin;
+        add_action( self::CRON_HOOK, [ self::class, 'on_cron' ] );
+    }
+
+    public static function enabled_for( ?\WP_Post $post ): bool {
+        if ( ! $post ) return false;
+        $s = Settings::get_instance();
+        return $s && in_array( $post->post_type, $s->rendered_types(), true );
+    }
+
+    public static function any_enabled(): bool {
+        $s = Settings::get_instance();
+        return $s && (bool) $s->rendered_types();
+    }
+
+    /** Read cached pages (Settings → Content → "Use cached pages"). */
+    public static function use_cache(): bool {
+        $s = Settings::get_instance();
+        return $s && (bool) $s->get( 'rendered_cached', false );
+    }
+
+    /**
+     * Fetch and read one article's published page. With cached pages on, an
+     * answer that doesn't show the article is fetched once more, fresh.
+     *
+     * @return array{links: array<int, array{0: string, 1: string}>, words: int, h2: int, images: int}|\WP_Error
+     */
+    public static function read( \WP_Post $post ) {
+        if ( ! class_exists( '\DOMDocument' ) ) {
+            return self::fail( $post, new \WP_Error( 'no_dom', __( 'The PHP DOM extension is not installed on this server.', 'clusterlane' ) ) );
+        }
+        $url = get_permalink( $post );
+        if ( ! $url ) return self::fail( $post, new \WP_Error( 'no_url', __( 'The article has no address.', 'clusterlane' ) ) );
+
+        $cached = self::use_cache();
+        $page   = self::attempt( $post, $cached );
+        if ( is_wp_error( $page ) && $cached ) {
+            $fresh = self::attempt( $post, false );
+            if ( ! is_wp_error( $fresh ) ) $page = $fresh;
+        }
+        if ( is_wp_error( $page ) ) return self::fail( $post, $page, (string) $url );
+        self::$batch['ok']++;
+        update_post_meta( $post->ID, self::STATS_META, [ 'words' => $page['words'], 'h2' => $page['h2'], 'images' => $page['images'] ] );
+        return $page;
+    }
+
+    /**
+     * Words, subheadings and images on the page when it was last read — used
+     * while a page waits in the queue or couldn't be read this time.
+     *
+     * @return array{words: int, h2: int, images: int}|null
+     */
+    public static function stored_stats( int $post_id ): ?array {
+        $m = get_post_meta( $post_id, self::STATS_META, true );
+        if ( ! is_array( $m ) ) return null;
+        return [ 'words' => (int) ( $m['words'] ?? 0 ), 'h2' => (int) ( $m['h2'] ?? 0 ), 'images' => (int) ( $m['images'] ?? 0 ) ];
+    }
+
+    /** The address to fetch: as visitors get it (cached), or past the page cache. */
+    private static function fetch_url( \WP_Post $post, bool $cached ): string {
+        $url = (string) get_permalink( $post );
+        // A throwaway query argument gets past page caches (plugins and CDNs cache
+        // by the full address), so an edit shows at once — e.g. after Undo.
+        return $cached ? $url : add_query_arg( 'clusterlane_read', (string) wp_rand( 100000, 999999 ), $url );
+    }
+
+    /** @return array<string, string> */
+    private static function fetch_headers( bool $cached ): array {
+        return $cached ? [] : [ 'Cache-Control' => 'no-cache' ];
+    }
+
+    private static function user_agent(): string {
+        return 'Mozilla/5.0 (compatible; Clusterlane/' . CLUSTERLANE_VERSION . '; reading this site\'s own pages)';
+    }
+
+    private static function ssl_verify(): bool {
+        // WordPress core's own setting for requests to the site itself (cron, Site Health).
+        return (bool) apply_filters( 'https_local_ssl_verify', false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter
+    }
+
+    /**
+     * One try: fetch the page through the WordPress HTTP API, then parse.
+     *
+     * @return array{links: array<int, array{0: string, 1: string}>, words: int, h2: int, images: int}|\WP_Error
+     */
+    private static function attempt( \WP_Post $post, bool $cached ) {
+        $res = wp_remote_get( self::fetch_url( $post, $cached ), [
+            'timeout'     => self::TIMEOUT,
+            'redirection' => 3,
+            'sslverify'   => self::ssl_verify(),
+            'user-agent'  => self::user_agent(),
+            'headers'     => self::fetch_headers( $cached ),
+        ] );
+        $raw = is_wp_error( $res )
+            ? new \WP_Error( 'http', $res->get_error_message() )
+            : [ 'code' => (int) wp_remote_retrieve_response_code( $res ), 'body' => (string) wp_remote_retrieve_body( $res ) ];
+        if ( is_wp_error( $raw ) ) return $raw;
+        if ( $raw['code'] !== 200 ) {
+            /* translators: %d: HTTP status code */
+            return new \WP_Error( 'status_' . $raw['code'], sprintf( __( 'The page answered with HTTP %d.', 'clusterlane' ), $raw['code'] ) );
+        }
+        return self::parse( $raw['body'], $post );
+    }
+
+    /* ------------------------------------------------------------------
+       Reading later (0.98.178)
+    ------------------------------------------------------------------ */
+
+    /** Scan the text now and queue the page (the Scan button's scan). */
+    public static function defer( bool $on ): void {
+        self::$defer = $on;
+    }
+
+    public static function deferring(): bool {
+        return self::$defer;
+    }
+
+    public static function queue( int $post_id ): void {
+        self::$queued[] = $post_id;
+    }
+
+    /**
+     * @return array{ids: int[], total: int, done: int, started: int, finished: int}
+     */
+    public static function queue_state(): array {
+        $q = get_option( self::QUEUE_OPTION, [] );
+        $q = is_array( $q ) ? $q : [];
+        return [
+            'ids'      => array_values( array_map( 'intval', (array) ( $q['ids'] ?? [] ) ) ),
+            'total'    => (int) ( $q['total'] ?? 0 ),
+            'done'     => (int) ( $q['done'] ?? 0 ),
+            'started'  => (int) ( $q['started'] ?? 0 ),
+            'finished' => (int) ( $q['finished'] ?? 0 ),
+        ];
+    }
+
+    /** A full scan starts: nothing queued. */
+    public static function reset_queue(): void {
+        delete_option( self::QUEUE_OPTION );
+        self::$queued = [];
+    }
+
+    /** Add this request's queued pages to the queue and have it read. */
+    public static function flush_queue(): int {
+        if ( ! self::$queued ) return 0;
+        $q   = self::queue_state();
+        $new = array_values( array_diff( array_unique( self::$queued ), $q['ids'] ) );
+        $q['ids']      = array_merge( $q['ids'], $new );
+        $q['total']    = $q['done'] + count( $q['ids'] );
+        $q['started']  = $q['started'] ?: time();
+        $q['finished'] = 0;
+        update_option( self::QUEUE_OPTION, $q, false );
+        self::$queued = [];
+        self::schedule();
+        return count( $new );
+    }
+
+    /** WP-Cron picks the queue up when no Clusterlane screen reads it. */
+    public static function schedule( int $delay = 60 ): void {
+        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) wp_schedule_single_event( time() + $delay, self::CRON_HOOK );
+    }
+
+    public static function on_cron(): void {
+        $st = self::run_queue();
+        if ( $st['left'] ) self::schedule( 5 );
+    }
+
+    /**
+     * Read the next pages in the queue for up to SLICE seconds; when the last
+     * one is read, update the metrics. One slice at a time (a lock), so the
+     * open screen and WP-Cron never read the same pages twice.
+     *
+     * @return array{active: bool, total: int, done: int, left: int, finished: int, busy?: bool, ok: int, failed: int, example: ?array}
+     */
+    public static function run_queue(): array {
+        $q = self::queue_state();
+        if ( ! $q['ids'] || ! self::$plugin ) return self::status();
+        if ( ! self::lock() ) return self::status() + [ 'busy' => true ];
+
+        if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 120 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- background batch of page reads; capped, and harmless where disabled
+        ignore_user_abort( true );
+        $scanner = self::$plugin->get( 'scanner' );
+        $start   = microtime( true );
+        $was     = self::$defer;
+        self::$defer = false;
+        try {
+            // A couple of pages at a time, started only well inside the slice.
+            while ( $q['ids'] && microtime( true ) - $start < self::SLICE - 6 ) {
+                $window = array_slice( $q['ids'], 0, 2 );
+                foreach ( $window as $id ) {
+                    if ( $scanner ) $scanner->scan_post( $id, true );
+                }
+                // Meanwhile a new scan may have started (reset) or added pages.
+                $cur = self::queue_state();
+                if ( $cur['started'] !== $q['started'] ) { $q = $cur; break; }
+                $q          = $cur;
+                $q['ids']   = array_values( array_diff( $cur['ids'], $window ) );
+                $q['done']  = $cur['done'] + count( $window );
+                $q['total'] = max( $cur['total'], $q['done'] + count( $q['ids'] ) );
+                update_option( self::QUEUE_OPTION, $q, false );
+            }
+            self::flush_log();
+            if ( ! $q['ids'] && ! $q['finished'] && $q['total'] ) {
+                $q['finished'] = time();
+                update_option( self::QUEUE_OPTION, $q, false );
+                $engine = self::$plugin->get( 'cluster_engine' );
+                if ( $engine ) $engine->compute_all();
+            }
+        } finally {
+            self::$defer = $was;
+            self::unlock();
+        }
+        return self::status();
+    }
+
+    /**
+     * Where the reading stands, for the screens.
+     *
+     * @return array{active: bool, total: int, done: int, left: int, finished: int, ok: int, failed: int, example: ?array}
+     */
+    public static function status(): array {
+        $q   = self::queue_state();
+        $log = self::log();
+        return [
+            'active'   => (bool) $q['ids'],
+            'total'    => $q['total'],
+            'done'     => $q['done'],
+            'left'     => count( $q['ids'] ),
+            'finished' => $q['finished'],
+            'ok'       => (int) ( $log['ok'] ?? 0 ),
+            'failed'   => (int) ( $log['failed'] ?? 0 ),
+            'example'  => $log['example'] ?? null,
+        ];
+    }
+
+    private static function lock(): bool {
+        if ( add_option( self::LOCK_OPTION, time(), '', false ) ) return true;
+        $t = (int) get_option( self::LOCK_OPTION, 0 );
+        if ( $t && time() - $t < self::SLICE + 100 ) return false; // someone is reading
+        update_option( self::LOCK_OPTION, time(), false );        // a slice that died: take over
+        return true;
+    }
+
+    private static function unlock(): void {
+        delete_option( self::LOCK_OPTION );
+    }
+
+    /**
+     * Read a page's HTML: links, words, subheadings and images in its main content.
+     * Words, subheadings and images inside links (cards, teasers of other
+     * articles) are not counted as this article's own.
+     *
+     * @return array{links: array<int, array{0: string, 1: string}>, words: int, h2: int, images: int}|\WP_Error
+     */
+    public static function parse( string $html, ?\WP_Post $post = null ) {
+        if ( trim( $html ) === '' ) return new \WP_Error( 'empty', __( 'The page was empty.', 'clusterlane' ) );
+        $doc  = new \DOMDocument();
+        $prev = libxml_use_internal_errors( true );
+        $ok   = $doc->loadHTML( '<?xml encoding="utf-8" ?>' . $html, LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_COMPACT );
+        libxml_clear_errors();
+        libxml_use_internal_errors( $prev );
+        if ( ! $ok ) return new \WP_Error( 'parse', __( 'The page could not be read.', 'clusterlane' ) );
+
+        $xp   = new \DOMXPath( $doc );
+        $main = self::main_node( $xp );
+        if ( ! $main ) {
+            return new \WP_Error( 'no_main', __( 'No main content area was found on the page (a <main> element or role="main").', 'clusterlane' ) );
+        }
+
+        // Not content: navigation and breadcrumbs, scripts, forms, comments, sidebars,
+        // and Clusterlane's own pillar box (stored separately as 'auto' links).
+        $ci  = fn( string $attr ): string => 'translate(' . $attr . ', "' . self::UPPER . '", "' . self::LOWER . '")';
+        $has = fn( string $attr, string $word ): string => 'contains(' . $ci( $attr ) . ', "' . $word . '")';
+        $drop = './/nav | .//script | .//style | .//noscript | .//template | .//form | .//iframe | .//svg | .//select'
+              . ' | .//*[contains(concat(" ", normalize-space(@class), " "), " clusterlane-pillar-box ")]'
+              . ' | .//*[' . $has( '@class', 'breadcrumb' ) . ' or ' . $has( '@id', 'breadcrumb' ) . ' or ' . $has( '@aria-label', 'breadcrumb' ) . ']'
+              . ' | .//*[@id="comments" or @id="respond" or contains(concat(" ", normalize-space(@class), " "), " comments-area ")]'
+              . ' | .//*[@id="secondary" or @role="complementary" or ' . $has( '@class', 'widget-area' ) . ' or contains(concat(" ", normalize-space(@class), " "), " sidebar ")]';
+        $nodes = $xp->query( $drop, $main );
+        foreach ( $nodes ? iterator_to_array( $nodes ) : [] as $n ) {
+            if ( $n->parentNode ) $n->parentNode->removeChild( $n );
+        }
+
+        if ( $post && ! self::looks_like( $post, $xp, $main ) ) {
+            return new \WP_Error( 'not_article', __( 'The page did not show the article (a login, maintenance or “coming soon” page?).', 'clusterlane' ) );
+        }
+
+        $links = [];
+        foreach ( $xp->query( './/a[@href]', $main ) ?: [] as $a ) {
+            if ( ! $a instanceof \DOMElement ) continue;
+            $href = trim( (string) $a->getAttribute( 'href' ) );
+            if ( $href === '' || $href[0] === '#' || preg_match( '#^(mailto|tel|javascript|data):#i', $href ) ) continue;
+            $links[] = [ html_entity_decode( $href, ENT_QUOTES, 'UTF-8' ), trim( preg_replace( '/\s+/u', ' ', (string) $a->textContent ) ) ];
+        }
+
+        $text = '';
+        foreach ( $xp->query( './/text()[not(ancestor::a)]', $main ) ?: [] as $t ) $text .= ' ' . $t->nodeValue;
+        $text  = trim( preg_replace( '/\s+/u', ' ', $text ) );
+        $words = $text === '' ? 0 : str_word_count( $text, 0, 'åäöÅÄÖéèêëàâæœùûüïîæçñáíóúÑÁÉÍÓÚ' );
+        $h2    = ( $l = $xp->query( './/h2[not(ancestor::a)]', $main ) ) ? $l->length : 0;
+        $imgs  = ( $l = $xp->query( './/img[not(ancestor::a)]', $main ) ) ? $l->length : 0;
+
+        return [ 'links' => $links, 'words' => (int) $words, 'h2' => (int) $h2, 'images' => (int) $imgs ];
+    }
+
+    /** The page's main content: <main>, role="main", then the usual theme ids, then the largest <article>. */
+    private static function main_node( \DOMXPath $xp ): ?\DOMNode {
+        foreach ( [ '//main', '//*[@role="main"]', '//*[@id="main"]', '//*[@id="primary"]', '//*[@id="content"]', '//article' ] as $q ) {
+            $best = null;
+            $len  = 0;
+            foreach ( $xp->query( $q ) ?: [] as $n ) {
+                $l = strlen( trim( (string) $n->textContent ) );
+                if ( $l > $len ) { $best = $n; $len = $l; }
+            }
+            if ( $best ) return $best;
+        }
+        return null;
+    }
+
+    /**
+     * Does the page show this article? Most of the title's words must be on it
+     * (in the main content or the document title) — a login wall, a maintenance
+     * or "coming soon" page doesn't have them.
+     */
+    private static function looks_like( \WP_Post $post, \DOMXPath $xp, \DOMNode $main ): bool {
+        // WordPress's body classes say which page this is (themes print them with
+        // body_class()): "postid-123" / "page-id-45" must be this article, and a
+        // home, blog, archive or 404 page never is — a home page listing the
+        // articles has their titles too, so the title test alone let it pass (0.98.179).
+        $cls = $xp->query( '//body/@class' );
+        if ( $cls && $cls->length ) {
+            $classes = preg_split( '/\s+/', strtolower( (string) $cls->item( 0 )->nodeValue ) ) ?: [];
+            foreach ( $classes as $c ) {
+                if ( preg_match( '/^(?:postid|page-id)-(\d+)$/', $c, $m ) ) return (int) $m[1] === (int) $post->ID;
+            }
+            if ( array_intersect( $classes, [ 'home', 'blog', 'archive', 'search', 'error404' ] ) && ! array_intersect( $classes, [ 'single', 'page', 'singular', 'wp-singular' ] ) ) return false;
+        }
+        $norm  = fn( string $s ): string => mb_strtolower( remove_accents( html_entity_decode( wp_strip_all_tags( $s ), ENT_QUOTES, 'UTF-8' ) ), 'UTF-8' ); // "Arguineguin" in the title, "Arguineguín" on the page
+        $words = array_values( array_filter( preg_split( '/[^\p{L}\p{N}]+/u', $norm( get_the_title( $post ) ) ) ?: [], fn( $w ) => mb_strlen( $w, 'UTF-8' ) >= 3 ) );
+        if ( ! $words ) return true;
+        $title = $xp->query( '//title' );
+        $page  = $norm( (string) $main->textContent . ' ' . ( $title && $title->length ? (string) $title->item( 0 )->textContent : '' ) );
+        $found = 0;
+        foreach ( $words as $w ) if ( str_contains( $page, $w ) ) $found++;
+        return $found / count( $words ) >= 0.6;
+    }
+
+    /** Record a failure for the scan report. */
+    private static function fail( \WP_Post $post, \WP_Error $e, string $url = '' ): \WP_Error {
+        self::$batch['failed']++;
+        $code = (string) $e->get_error_code();
+        self::$batch['reasons'][ $code ] = ( self::$batch['reasons'][ $code ] ?? 0 ) + 1;
+        if ( ! self::$batch['example'] ) {
+            self::$batch['example'] = [ 'post' => (int) $post->ID, 'url' => $url ?: (string) get_permalink( $post ), 'message' => $e->get_error_message() ];
+        }
+        return $e;
+    }
+
+    /** This batch's counts (for the scan response), then add them to the scan's log. */
+    public static function flush_log(): array {
+        $b = self::$batch;
+        self::$batch = [ 'ok' => 0, 'failed' => 0, 'reasons' => [], 'example' => null ];
+        if ( ! $b['ok'] && ! $b['failed'] ) return $b;
+        $log = get_option( self::LOG_OPTION, [] );
+        $log = is_array( $log ) ? $log : [];
+        $log['ok']     = (int) ( $log['ok'] ?? 0 ) + $b['ok'];
+        $log['failed'] = (int) ( $log['failed'] ?? 0 ) + $b['failed'];
+        foreach ( $b['reasons'] as $k => $n ) $log['reasons'][ $k ] = (int) ( $log['reasons'][ $k ] ?? 0 ) + $n;
+        if ( empty( $log['example'] ) && $b['example'] ) $log['example'] = $b['example'];
+        $log['at'] = time();
+        update_option( self::LOG_OPTION, $log, false );
+        return $b;
+    }
+
+    /** A full scan starts: forget the previous one's counts. */
+    public static function reset_log(): void {
+        delete_option( self::LOG_OPTION );
+    }
+
+    /** @return array{ok?: int, failed?: int, reasons?: array<string,int>, example?: array, at?: int} */
+    public static function log(): array {
+        $log = get_option( self::LOG_OPTION, [] );
+        return is_array( $log ) ? $log : [];
+    }
+
+    /**
+     * Links in the article TEXT per page, by post type — the signal that a theme
+     * links its pages through templates instead. Counted from the stored text, so
+     * it works before anything is scanned (onboarding, Settings).
+     *
+     * @param  string[] $types
+     * @return array<string, array{posts: int, per_page: float}>
+     */
+    public static function text_link_density( array $types ): array {
+        global $wpdb;
+        $types = array_values( array_filter( array_map( 'sanitize_key', $types ) ) );
+        if ( ! $types ) return [];
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            "SELECT post_type, COUNT(*) AS posts,
+                    SUM( ( CHAR_LENGTH( LOWER( post_content ) ) - CHAR_LENGTH( REPLACE( LOWER( post_content ), '<a ', '' ) ) ) / 3 ) AS links
+             FROM {$wpdb->posts}
+             WHERE post_status = 'publish' AND post_password = '' AND post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')
+             GROUP BY post_type',
+            $types
+        ) );
+        $out = [];
+        foreach ( (array) $rows as $r ) {
+            $n = (int) $r->posts;
+            $out[ $r->post_type ] = [ 'posts' => $n, 'per_page' => $n ? round( (float) $r->links / $n, 1 ) : 0.0 ];
+        }
+        return $out;
+    }
+
+    /**
+     * Post types whose pages have almost no links in their text (under two per
+     * page, at least 5 pages): reading the published page is recommended.
+     *
+     * @param  string[] $types
+     * @return string[]
+     */
+    public static function recommended( array $types ): array {
+        $out = [];
+        foreach ( self::text_link_density( $types ) as $pt => $d ) {
+            if ( self::is_recommended( $d ) ) $out[] = $pt;
+        }
+        return $out;
+    }
+
+    /**
+     * At least 5 pages and under two links per page in the text. (Under one
+     * missed a theme's island pages with exactly one link each in the text,
+     * whose area cards were the only links to 40 area pages.)
+     *
+     * @param array{posts: int, per_page: float} $d
+     */
+    public static function is_recommended( array $d ): bool {
+        return $d['posts'] >= 5 && $d['per_page'] < 2;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-plugin.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-plugin.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-plugin.php	(revision 3751027)
@@ -0,0 +1,183 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Plugin
+ *
+ * Central bootstrap class. Wires up all services and registers hooks.
+ * Keeps the main plugin file clean — it just calls boot() here.
+ */
+class Plugin {
+
+    /** @var array<string, object> Simple service container. */
+    private array $services = [];
+
+    /* ------------------------------------------------------------------
+       Boot
+    ------------------------------------------------------------------ */
+
+    public function boot(): void {
+        // Once per request, even if a second copy of the plugin file got loaded.
+        static $booted = false;
+        if ( $booted ) return;
+        $booted = true;
+
+        // Load core dependencies explicitly.
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-installer.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-capabilities.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-edition.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-brand.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-legacy.php';
+
+        // ClusterIQ (the earlier name): take over its data once, before the
+        // capability mapping and DB version checks below read their options.
+        // While ClusterIQ is still active Clusterlane waits — only a notice
+        // asks the user to deactivate it.
+        if ( ! Legacy::maybe_migrate() ) return;
+
+        // Capabilities: lock-out safety net + grant role caps on first boot
+        // (also covers existing installs and multisite sub-sites, where the
+        // activation hook never ran).
+        Capabilities::register_fallback();
+        Capabilities::maybe_install();
+
+        // Multisite: install on new sites, drop tables on site deletion.
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-multisite.php';
+        Multisite::register_hooks();
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-compat.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-scope.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-relevance.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-seo-meta.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-html-places.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-cluster-sections.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-lang.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-shop.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-thumbs.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-page-reader.php';   // published pages (0.98.177)
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-cluster-types.php'; // clusters from any taxonomy (0.98.177)
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-hub-clusters.php';  // pages as clusters (0.98.180)
+
+        // Premium code (Pro/Agency) — a folder that is NOT in the free build.
+        // It hooks itself in via the clusterlane_* actions below; Edition caps the
+        // tier at 'free' when it is absent.
+        $premium = CLUSTERLANE_DIR . 'intelligence/pro__premium_only/bootstrap.php';
+        if ( is_readable( $premium ) ) {
+            require_once $premium;
+        }
+
+        // Run DB migrations if needed.
+        Installer::maybe_upgrade();
+        Brand::register_hooks(); // white label: translated texts follow the brand name
+
+        // Guard: if the tables still don't exist after maybe_upgrade()
+        // (e.g. the activation ran into a MySQL error), show a clear admin
+        // notice and bail out early instead of crashing with a DB error
+        // somewhere deep inside a REST callback.
+        if ( ! Installer::tables_exist() ) {
+            add_action( 'admin_notices', function () {
+                echo '<div class="notice notice-error"><p>'
+                    . '<strong>Clusterlane:</strong> '
+                    . esc_html__( 'Database tables are missing. Please deactivate and reactivate the plugin to repair the installation.', 'clusterlane' )
+                    . '</p></div>';
+            } );
+            return;
+        }
+
+        // Register services.
+        $this->register_services();
+
+        // History (daily snapshots) and the activity log — every edition,
+        // because history can't be recreated afterwards.
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-history.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-activity.php';
+        History::register_hooks( $this );
+        PageReader::register_hooks( $this ); // published pages read after a scan (0.98.178)
+        Activity::register_hooks();
+
+        // Clusterlane in the toolbar — on the site too, for "Inspect this article".
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-toolbar.php';
+        \Clusterlane\Admin\Toolbar::register();
+
+        // Admin-only hooks.
+        if ( is_admin() ) {
+            $this->boot_admin();
+        }
+
+        // REST API endpoints (available to authenticated requests always).
+        add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
+    }
+
+    /* ------------------------------------------------------------------
+       Services
+    ------------------------------------------------------------------ */
+
+    private function register_services(): void {
+        // Explicit requires — more reliable than autoloader during initial setup.
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-settings.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-link-graph.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-scanner.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-cluster-engine.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/core/class-text-match.php';
+
+        $this->services['settings']       = new \Clusterlane\Core\Settings();
+        $this->services['link_graph']     = new \Clusterlane\Core\LinkGraph();
+        $this->services['scanner']        = new \Clusterlane\Core\Scanner( $this->services['link_graph'] );
+        $this->services['cluster_engine'] = new \Clusterlane\Core\ClusterEngine();
+        $this->services['text_match']     = new \Clusterlane\Core\TextMatch();
+
+        // Premium adds its services here (e.g. ai_client).
+        do_action( 'clusterlane_register_services', $this );
+    }
+
+    public function get( string $key ): ?object {
+        return $this->services[ $key ] ?? null;
+    }
+
+    /** Add a service — used by premium code from clusterlane_register_services. */
+    public function set( string $key, object $service ): void {
+        $this->services[ $key ] = $service;
+    }
+
+    /* ------------------------------------------------------------------
+       Admin
+    ------------------------------------------------------------------ */
+
+    private function boot_admin(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-inline.php';   // page scripts via wp_add_inline_script (1.3.0)
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-subnav.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-help.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/admin/class-admin.php';
+        $admin = new \Clusterlane\Admin\Admin( $this );
+        $admin->register();
+
+        // Premium admin screens (Network Admin page, AI settings, …).
+        do_action( 'clusterlane_boot_admin', $this );
+    }
+
+    /* ------------------------------------------------------------------
+       REST
+    ------------------------------------------------------------------ */
+
+    public function register_rest_routes(): void {
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-clusters.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-scanner.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-text-match.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-graph.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-article-inspector.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-cluster-inspector.php';
+        require_once CLUSTERLANE_DIR . 'intelligence/api/class-rest-seo.php';
+
+        ( new \Clusterlane\Api\RestClusters( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestScanner( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestTextMatch( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestGraph( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestArticleInspector( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestClusterInspector( $this ) )->register_routes();
+        ( new \Clusterlane\Api\RestSeo( $this ) )->register_routes();
+
+        // Premium REST routes (AI, link suggestions, network AI test, …).
+        do_action( 'clusterlane_register_rest_routes', $this );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-relevance.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-relevance.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-relevance.php	(revision 3751027)
@@ -0,0 +1,150 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Relevance — how naturally one article could link to another, without AI.
+ *
+ * Clusters say two articles share a theme; this says whether they actually
+ * talk about the same things. Three cheap signals, all from the database:
+ *
+ *   - word overlap   title (×3), tags (×2) and opening text (×1), cosine-style
+ *   - mention        the source already names the target's subject
+ *                    (e.g. "Alvik" in a hotel round-up → link to "Hotels in Alvik")
+ *   - shared tags    same WordPress tags
+ *
+ * score() returns 0–100 plus short, human reasons for the UI.
+ */
+final class Relevance {
+
+    /** @var array<int, array{w: array<string,float>, text: string, tags: string[]}> */
+    private static array $cache = [];
+
+    private const STOP = [
+        // English
+        'the','and','for','with','from','your','you','this','that','are','was','were','has','have','had','its','our','their',
+        'into','over','more','most','best','guide','what','when','where','which','how','why','about','all','any','can','will',
+        'visit','visiting','things','top','also','just','than','then','them','they','there','here','one','two','new','near',
+        // Swedish
+        'och','för','med','som','det','den','att','till','från','på','av','är','var','har','inte','ett','en','om','eller',
+        'men','kan','ska','vid','över','efter','under','mot','alla','sin','sina','dess','denna','detta','dessa',
+        'här','där','när','hur','vad','vem','sedan','också','bara','mycket','många','finns','blir','kommer','även','eller','samt','inom','mellan','utan','sig','man','den','det','de','vi','ni','han','hon',
+        'would','could','should','into','onto','very','much','many','some','such','only','other','there','these','those','where',
+    ];
+
+    /** Weighted word profile of a post (cached per request). */
+    private static function profile( int $post_id ): array {
+        if ( isset( self::$cache[ $post_id ] ) ) return self::$cache[ $post_id ];
+
+        $post = get_post( $post_id );
+        if ( ! $post ) return self::$cache[ $post_id ] = [ 'w' => [], 'text' => '', 'tags' => [] ];
+
+        $plain = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
+        $plain = html_entity_decode( $plain, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+        $open  = implode( ' ', array_slice( preg_split( '/\s+/u', $post->post_excerpt . ' ' . $plain ), 0, 120 ) );
+        $title = html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+        $tags  = get_the_terms( $post_id, 'post_tag' ); // cached (wp_get_post_terms() queries every time)
+        $tags  = ( ! $tags || is_wp_error( $tags ) ) ? [] : array_map( fn( $t ) => html_entity_decode( $t->name, ENT_QUOTES, 'UTF-8' ), $tags );
+
+        $w = [];
+        foreach ( [ [ $title, 3.0 ], [ implode( ' ', $tags ), 2.0 ], [ $open, 1.0 ] ] as [ $txt, $weight ] ) {
+            foreach ( self::words( $txt ) as $word ) {
+                $w[ $word ] = ( $w[ $word ] ?? 0 ) + $weight;
+            }
+        }
+        return self::$cache[ $post_id ] = [ 'w' => $w, 'text' => mb_strtolower( $title . ' ' . $plain, 'UTF-8' ), 'tags' => $tags ];
+    }
+
+    /** @return string[] */
+    private static function words( string $txt ): array {
+        preg_match_all( '/[\p{L}\p{N}]{3,}/u', mb_strtolower( $txt, 'UTF-8' ), $m );
+        return array_values( array_filter( $m[0], fn( $w ) => ! in_array( $w, self::STOP, true ) && ! ctype_digit( $w ) ) );
+    }
+
+    /** @var array<string,int>|null word → number of site titles containing it */
+    private static ?array $df = null;
+    private static int $titles = 0;
+
+    /** How many published titles contain each word (once per request). */
+    private static function df( string $word ): int {
+        if ( self::$df === null ) {
+            global $wpdb;
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $rows = $wpdb->get_col( $wpdb->prepare( 'SELECT post_title FROM %i WHERE ' . Scope::type_sql() . ' AND ' . Scope::sql(), $wpdb->posts ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- titles once per request; type_sql() is prepared, sql() has no values
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+            self::$titles = count( $rows );
+            self::$df = [];
+            foreach ( $rows as $t ) {
+                foreach ( array_unique( self::words( html_entity_decode( $t, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ) as $w ) {
+                    self::$df[ $w ] = ( self::$df[ $w ] ?? 0 ) + 1;
+                }
+            }
+        }
+        return self::$df[ $word ] ?? 0;
+    }
+
+    /** A word is distinctive when few titles on the site use it ("Alvik" yes, "Hotels"/"Stockholm" no). */
+    private static function distinctive( string $word ): bool {
+        self::df( $word );
+        return self::df( $word ) <= max( 3, (int) ceil( self::$titles * 0.04 ) );
+    }
+
+    /**
+     * The target's distinctive name words, as written in its title
+     * ("Hotels in Alvik: Best Stays…" → ["Alvik"]).
+     *
+     * @return string[]
+     */
+    private static function subject_words( int $post_id ): array {
+        $t = html_entity_decode( get_the_title( $post_id ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+        $t = trim( preg_split( '/\s*[:–—|(]\s*|\s+-\s+/u', $t )[0] ?? $t );
+        preg_match_all( '/[\p{L}\p{N}][\p{L}\p{N}’\'-]{2,}/u', $t, $m );
+        $out = [];
+        foreach ( $m[0] as $w ) {
+            $lw = mb_strtolower( $w, 'UTF-8' );
+            if ( in_array( $lw, self::STOP, true ) || mb_strlen( $lw ) < 4 ) continue;
+            if ( self::distinctive( $lw ) ) $out[] = $w;
+        }
+        return $out;
+    }
+
+    /**
+     * How well $source fits as a place to link to $target.
+     *
+     * @return array{score:int, reasons:string[]}
+     */
+    public static function score( int $source_id, int $target_id ): array {
+        $a = self::profile( $source_id );
+        $b = self::profile( $target_id );
+        $reasons = [];
+
+        // Cosine-style overlap of weighted word profiles.
+        $dot = 0.0; $shared = [];
+        foreach ( $b['w'] as $word => $wb ) {
+            if ( isset( $a['w'][ $word ] ) ) { $dot += min( $a['w'][ $word ], $wb ); $shared[ $word ] = $a['w'][ $word ] * $wb; }
+        }
+        $na = array_sum( $a['w'] ); $nb = array_sum( $b['w'] );
+        $overlap = ( $na > 0 && $nb > 0 ) ? $dot / sqrt( $na * $nb ) : 0.0;   // ~0–1
+
+        // Does the source already name the target's subject (its distinctive words)?
+        $named = [];
+        foreach ( self::subject_words( $target_id ) as $w ) {
+            if ( preg_match( '/(?<![\p{L}\p{N}])' . preg_quote( mb_strtolower( $w, 'UTF-8' ), '/' ) . '(?![\p{L}\p{N}])/u', $a['text'] ) ) $named[] = $w;
+        }
+        $mentions = (bool) $named;
+        if ( $mentions ) $reasons[] = sprintf( /* translators: %s: text */ __( 'Mentions %s', 'clusterlane' ), '“' . implode( '”, “', array_slice( $named, 0, 2 ) ) . '”' );
+
+        $tags = array_values( array_intersect( array_map( 'mb_strtolower', $a['tags'] ), array_map( 'mb_strtolower', $b['tags'] ) ) );
+        if ( $tags ) $reasons[] = sprintf( /* translators: %s: text */ _n( 'Shared tag: %s', 'Shared tags: %s', count( $tags ), 'clusterlane' ), implode( ', ', array_slice( $tags, 0, 3 ) ) );
+
+        if ( ! $mentions && $shared ) {
+            arsort( $shared );
+            $top = array_slice( array_keys( array_filter( $shared, fn( $v, $w ) => self::distinctive( $w ), ARRAY_FILTER_USE_BOTH ) ), 0, 3 );
+            if ( $top ) $reasons[] = sprintf( /* translators: %s: text */ __( 'Both about %s', 'clusterlane' ), implode( ', ', $top ) );
+        }
+
+        $score = min( 1.0, $overlap * 2.2 ) * 60 + ( $mentions ? 30 : 0 ) + min( 10, count( $tags ) * 5 );
+        return [ 'score' => (int) round( min( 100, $score ) ), 'reasons' => $reasons ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-scanner.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-scanner.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-scanner.php	(revision 3751027)
@@ -0,0 +1,316 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Scanner
+ *
+ * Scans published posts and populates clusterlane_links + clusterlane_post_metrics.
+ */
+class Scanner {
+
+    public function __construct( private LinkGraph $graph ) {}
+
+    /**
+     * Scan a single post: extract links, save to DB, update metrics.
+     * Returns number of outbound links found.
+     */
+    /**
+     * @param bool $keep_cluster Keep the article's primary cluster (single rescans after
+     *                           Apply/Undo). A full scan clears it so Metrics reassigns it.
+     */
+    public function scan_post( int $post_id, bool $keep_cluster = false ): int {
+        $post = get_post( $post_id );
+        if ( Scope::excludes( $post ) ) {   // unpublished or password-protected
+            Scope::forget_post( $post_id );
+            return 0;
+        }
+
+        // Extract and save links: the article text, plus — where Settings → Content
+        // says so — the links the theme adds on the published page (0.98.177).
+        // A scan from the Scan button reads the text now and queues the page
+        // (0.98.178): until it is read, its links and counts from the last
+        // reading stay — as when a page can't be read this time.
+        $links = $this->graph->extract_links( $post_id );
+        $page  = null;
+        $kept  = null;   // the page's words/H2s/images from its last reading
+        if ( PageReader::enabled_for( $post ) ) {
+            if ( PageReader::deferring() ) {
+                PageReader::queue( $post_id );
+                $page = false;
+            } else {
+                $page = PageReader::read( $post );
+            }
+            if ( ! is_array( $page ) ) $kept = PageReader::stored_stats( $post_id );
+        }
+        if ( is_array( $page ) ) {
+            $this->graph->save_links( $post_id, $links, $this->graph->resolve_links( $page['links'], $post_id ) );
+        } elseif ( $page !== null ) {
+            $this->graph->save_links( $post_id, $links );      // queued, or unreadable this time: keep its stored page links
+        } else {
+            $this->graph->save_links( $post_id, $links, [] );  // not read: no page links
+        }
+        if ( ! is_array( $page ) && $kept ) $page = $kept + [ 'links' => [] ];
+
+        // Build metrics.
+        $content  = $post->post_content;
+        $product  = Shop::is_product( $post ); // WooCommerce (0.98.172): description + short description
+        $text     = wp_strip_all_tags( $content ) . ( $product ? ' ' . Shop::short_description( $post ) : '' );
+        $words    = $text ? str_word_count( $text, 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) : 0;
+        $h2s      = substr_count( strtolower( $content ), '<h2' );
+        // The published page, when read, is what visitors see: text from custom
+        // fields and templates counts too (never less than the article text).
+        if ( is_array( $page ) ) {
+            $words = max( $words, $page['words'] );
+            $h2s   = max( $h2s, $page['h2'] );
+        }
+
+        // Count images: direct <img> tags + common gallery/image shortcodes.
+        // Many page builders (Envira, NextGEN, native [gallery]) store images as
+        // shortcodes rather than <img> tags in post_content.
+        $img_tags  = substr_count( strtolower( $content ), '<img' );
+        $built_in  = 'gallery|envira-gallery|envira_gallery|ngg|nextgen|vc_single_image|et_pb_image'
+                   . '|ml-slider|soliloquy|foogallery|modula|robo-gallery|tiled-gallery';
+
+        // Merge user-defined extra shortcodes from Settings.
+        $settings      = \Clusterlane\Core\Settings::get_instance();
+        $extra_sc      = $settings ? $settings->gallery_shortcodes() : [];
+        $extra_pattern = $extra_sc
+            ? '|' . implode( '|', array_map( fn($s) => preg_quote( $s, '/' ), $extra_sc ) )
+            : '';
+
+        $gallery_sc = preg_match_all(
+            '/\[\s*(' . $built_in . $extra_pattern . ')\b/i',
+            $content
+        );
+        $images = $img_tags + (int) $gallery_sc + ( $product ? Shop::gallery_count( $post_id ) : 0 );
+        if ( is_array( $page ) ) $images = max( $images, $page['images'] );
+        $inbound  = $this->graph->count_inbound( $post_id );
+        $outbound = $this->graph->count_outbound( $post_id ); // in-text + auto (pillar box) + template (page)
+
+        // Derive content class.
+        $class = $this->classify( $inbound, $outbound );
+
+        // Simple SEO score (can be enhanced later).
+        $seo_score = $this->compute_seo_score( $post, $words, $product );
+
+        // Simple structure score.
+        $structure_score = $product
+            ? $this->compute_product_score( $post, $words, $images )
+            : $this->compute_structure_score( $words, $h2s, $images );
+
+        // Overall score — weighted average. A product is not expected to link out; what
+        // matters is articles linking TO it (5 give full marks for that part).
+        $overall = (int) round( $product
+            ? $seo_score * 0.35 + $structure_score * 0.35 + min( 100, $inbound * 20 ) * 0.30
+            : $seo_score       * 0.35 +
+              $structure_score * 0.35 +
+              min( 100, $inbound  * 10 ) * 0.15 +
+              min( 100, $outbound * 10 ) * 0.15
+        );
+
+        global $wpdb;
+        // REPLACE rewrites the whole row. A single rescan after Apply/Undo
+        // carries over primary_cluster (set by Metrics) — before 0.98.124 the
+        // article's cluster went blank in lists until the next Metrics run.
+        $primary = $keep_cluster
+            ? $wpdb->get_var( $wpdb->prepare( "SELECT primary_cluster FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $post_id ) ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+            : null;
+        $wpdb->replace( $wpdb->prefix . 'clusterlane_post_metrics', [
+            'post_id'         => $post_id,
+            'primary_cluster' => $primary,
+            'seo_score'       => $seo_score,
+            'structure_score' => $structure_score,
+            'overall_score'   => min( 100, $overall ),
+            'inbound_links'   => $inbound,
+            'outbound_links'  => $outbound,
+            'word_count'      => $words,
+            'image_count'     => $images,
+            'h2_count'        => $h2s,
+            'content_class'   => $class,
+            'last_scanned'    => current_time( 'mysql', true ), // UTC — matches PHP time()
+        ] );
+        // Keep the role from the article's clusters (the last Metrics) instead of
+        // the first guess above — else a pillar showed as "support" after Apply.
+        if ( class_exists( ClusterEngine::class ) ) ClusterEngine::sync_site_roles( [ $post_id ] );
+
+        return $outbound;
+    }
+
+    /**
+     * Refresh every article's inbound and outbound counts from the link graph
+     * (and, where they changed, its overall score and first-guess role).
+     *
+     * scan_post() counts an article's inbound links when it scans that article,
+     * so links from articles scanned after it aren't counted until the next
+     * scan. On a first scan (an empty link graph) that made the first articles
+     * look like orphans, and a site without clusters kept those numbers, since
+     * Metrics never ran for it (0.98.179). Metrics (ClusterEngine::compute_all)
+     * calls this first; the scans end with Metrics.
+     */
+    public static function refresh_counts(): int {
+        global $wpdb;
+        $shop = Shop::active() ? 1 : 0;
+        // Same formulas as scan_post() (overall score, classify()); a product is scored on inbound links only.
+        // i.n / o.n: inbound / outbound links counted from the link table (NULL → 0).
+        return (int) $wpdb->query( $wpdb->prepare(
+            "UPDATE %i m
+            JOIN %i p ON p.ID = m.post_id
+            LEFT JOIN ( SELECT target_id, COUNT(*) AS n FROM %i WHERE target_type = 'post' GROUP BY target_id ) i ON i.target_id = m.post_id
+            LEFT JOIN ( SELECT source_id, COUNT(*) AS n FROM %i GROUP BY source_id ) o ON o.source_id = m.post_id
+            SET m.overall_score = LEAST( 100, ROUND( IF( %d = 1 AND p.post_type = 'product',
+                    m.seo_score * 0.35 + m.structure_score * 0.35 + LEAST( 100, COALESCE( i.n, 0 ) * 20 ) * 0.30,
+                    m.seo_score * 0.35 + m.structure_score * 0.35 + LEAST( 100, COALESCE( i.n, 0 ) * 10 ) * 0.15 + LEAST( 100, COALESCE( o.n, 0 ) * 10 ) * 0.15 ) ) ),
+                m.content_class = CASE WHEN COALESCE( i.n, 0 ) = 0 THEN 'orphan'
+                                       WHEN m.content_class = 'orphan' THEN IF( COALESCE( i.n, 0 ) >= 5 OR COALESCE( o.n, 0 ) >= 3, 'support', 'light' )
+                                       ELSE m.content_class END,
+                m.inbound_links  = COALESCE( i.n, 0 ),
+                m.outbound_links = COALESCE( o.n, 0 )
+            WHERE m.inbound_links <> COALESCE( i.n, 0 ) OR m.outbound_links <> COALESCE( o.n, 0 )",
+            $wpdb->prefix . 'clusterlane_post_metrics', $wpdb->posts, $wpdb->prefix . 'clusterlane_links', $wpdb->prefix . 'clusterlane_links', $shop
+        ) );
+    }
+
+    /**
+     * Get all published post IDs for the configured post types.
+     *
+     * @param  string[] $post_types
+     * @return int[]
+     */
+    public function get_all_post_ids( array $post_types = ['post'] ): array {
+        return get_posts( Scope::query_args( [
+            'post_type'              => $post_types,
+            'post_status'            => 'publish',
+            'posts_per_page'         => -1,
+            'fields'                 => 'ids',
+            'no_found_rows'          => true,
+            'update_post_meta_cache' => false,
+            'update_post_term_cache' => false,
+            'orderby'                => 'ID',
+            'order'                  => 'ASC',
+        ] ) );
+    }
+
+    /**
+     * Quick summary stats from the metrics table.
+     */
+    public function get_summary(): array {
+        global $wpdb;
+        $table = $wpdb->prefix . 'clusterlane_post_metrics';
+
+        $row = $wpdb->get_row( $wpdb->prepare(
+            'SELECT
+                COUNT(*)                          AS total_scanned,
+                ROUND(AVG(overall_score), 1)      AS avg_score,
+                ROUND(AVG(inbound_links), 1)      AS avg_inbound,
+                ROUND(AVG(outbound_links), 1)     AS avg_outbound,
+                SUM(inbound_links = 0)            AS orphan_count,
+                MAX(last_scanned)                 AS last_scan
+             FROM %i',
+            $table
+        ), ARRAY_A );
+
+        // Same scope as the scan (published, not password-protected).
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $total_posts = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE ' . Scope::type_sql() . ' AND ' . Scope::sql(), $wpdb->posts ) ); // type_sql() is prepared; sql() has no values
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+
+        return [
+            'total_posts'   => $total_posts,
+            'total_scanned' => (int)   ( $row['total_scanned'] ?? 0 ),
+            'avg_score'     => (float) ( $row['avg_score']     ?? 0 ),
+            'avg_inbound'   => (float) ( $row['avg_inbound']   ?? 0 ),
+            'avg_outbound'  => (float) ( $row['avg_outbound']  ?? 0 ),
+            'orphan_count'  => (int)   ( $row['orphan_count']  ?? 0 ),
+            'last_scan'     => $row['last_scan'] ?? null,
+        ];
+    }
+
+    /* ------------------------------------------------------------------
+       Private helpers
+    ------------------------------------------------------------------ */
+
+    /**
+     * First guess until Metrics runs. Never "pillar": a pillar is decided per
+     * cluster (at most one, or the one you chose), and Metrics then gives each
+     * article its best role across its clusters (ClusterEngine::sync_site_roles).
+     * Before 0.98.145 every article with 5+ inbound links was a "pillar" here.
+     */
+    private function classify( int $inbound, int $outbound ): string {
+        if ( $inbound === 0 )  return 'orphan';
+        if ( $inbound >= 5 || $outbound >= 3 ) return 'support';
+        return 'light';
+    }
+
+    private function compute_seo_score( \WP_Post $post, int $words, bool $product = false ): int {
+        $score = 0;
+
+        // SEO title + meta description from the configured source (SeoMeta:
+        // SEO plugin or custom/ACF fields, with post title/excerpt fallback).
+        // Lengths in characters, not bytes — "å" counts once.
+        $seo = \Clusterlane\Core\SeoMeta::get( $post );
+
+        // Title present and reasonable length.
+        $title_len = \Clusterlane\Core\SeoMeta::len( $seo['title'] );
+        if ( $title_len >= 20 && $title_len <= 70 ) $score += 30;
+        elseif ( $title_len > 0 ) $score += 15;
+
+        // Meta description (or excerpt fallback).
+        if ( \Clusterlane\Core\SeoMeta::len( $seo['description'] ) >= 50 ) $score += 25;
+
+        // Word count (a product page needs far fewer words than an article).
+        if ( $product ) {
+            if ( $words >= 150 )     $score += 30;
+            elseif ( $words >= 50 )  $score += 15;
+        } elseif ( $words >= 600 ) $score += 30;
+        elseif ( $words >= 300 )   $score += 15;
+
+        // Has a featured image.
+        if ( has_post_thumbnail( $post->ID ) ) $score += 15;
+
+        return min( 100, $score );
+    }
+
+    /**
+     * Product page (0.98.172), 0–100: a description of 150+ words (40, 20 from 50),
+     * a short description of 10+ words (30), and images beyond the main one —
+     * gallery or in the text (30 for 2+, 15 for 1).
+     */
+    private function compute_product_score( \WP_Post $post, int $words, int $images ): int {
+        $score = 0;
+        if ( $words >= 150 )     $score += 40;
+        elseif ( $words >= 50 )  $score += 20;
+        if ( str_word_count( Shop::short_description( $post ), 0, 'åäöÅÄÖéèêëàâæœùûüïîæç' ) >= 10 ) $score += 30;
+        if ( $images >= 2 )      $score += 30;
+        elseif ( $images >= 1 )  $score += 15;
+        return min( 100, $score );
+    }
+
+    private function compute_structure_score( int $words, int $h2s, int $images ): int {
+        $score = 0;
+
+        // Word count.
+        if ( $words >= 800 )      $score += 30;
+        elseif ( $words >= 400 )  $score += 20;
+        elseif ( $words >= 200 )  $score += 10;
+
+        // H2 headings relative to word count.
+        $expected_h2s = max( 1, intdiv( $words, 200 ) );
+        if ( $h2s >= $expected_h2s ) $score += 40;
+        elseif ( $h2s > 0 )          $score += 20;
+
+        // Images.
+        $img_per_1000 = $words > 0 ? ( $images / $words ) * 1000 : 0;
+        if ( $img_per_1000 >= 2 )      $score += 30;
+        elseif ( $img_per_1000 >= 0.5 ) $score += 15;
+
+        return min( 100, $score );
+    }
+}
+
Index: /clusterlane/tags/1.3.2/intelligence/core/class-scope.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-scope.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-scope.php	(revision 3751027)
@@ -0,0 +1,114 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * Scope — which posts Clusterlane analyses.
+ *
+ * Published posts only, and by default NOT password-protected ones: their
+ * content is hidden from visitors and search engines, so they shouldn't
+ * count towards cluster health, orphans or scores, or be offered as link
+ * targets. Opt back in with:
+ *
+ *     add_filter( 'clusterlane_include_protected_posts', '__return_true' );
+ *
+ * Every post query in Clusterlane goes through these helpers, so the rule
+ * lives in one place.
+ */
+final class Scope {
+
+    public static function include_protected(): bool {
+        return (bool) apply_filters( 'clusterlane_include_protected_posts', false );
+    }
+
+    /** get_posts()/WP_Query args with the scope applied. */
+    public static function query_args( array $args ): array {
+        if ( ! self::include_protected() ) {
+            $args['has_password'] = false;
+        }
+        // Shops (0.98.172): products hidden from the catalogue are out of scope.
+        if ( Shop::in_scope() ) {
+            $tq   = isset( $args['tax_query'] ) ? (array) $args['tax_query'] : [];
+            $tq[] = [ 'taxonomy' => 'product_visibility', 'field' => 'slug', 'terms' => [ 'exclude-from-catalog' ], 'operator' => 'NOT IN' ];
+            if ( count( $tq ) > 1 && ! isset( $tq['relation'] ) ) $tq['relation'] = 'AND';
+            $args['tax_query'] = $tq; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- WooCommerce catalog visibility, only on shops
+        }
+        return $args;
+    }
+
+    /** SQL condition for a posts table alias: published (+ not protected). */
+    public static function sql( string $alias = '' ): string {
+        $a = $alias !== '' ? $alias . '.' : '';
+        return "{$a}post_status = 'publish'" . ( self::include_protected() ? '' : " AND {$a}post_password = ''" ) . Shop::hidden_sql( $alias );
+    }
+
+    /**
+     * The post types Clusterlane analyses (Settings → Content → Post types).
+     * Clusters, Text Match, counts and lists all use this — before 0.98.114
+     * several places assumed 'post' only, so pages and custom post types
+     * were scanned but never became cluster members or link targets.
+     * @return string[]
+     */
+    public static function post_types(): array {
+        $s     = Settings::get_instance();
+        $types = $s ? (array) $s->post_types() : [ 'post' ];
+        $types = array_values( array_unique( array_filter( array_map( 'sanitize_key', $types ) ) ) );
+        return $types ?: [ 'post' ];
+    }
+
+    /**
+     * SQL condition: post_type IN (…) for a posts table alias, prepared here
+     * (one %s placeholder per post type). $with_pages adds 'page' — link
+     * targets are often pages even when pages aren't scanned.
+     */
+    public static function type_sql( string $alias = '', bool $with_pages = false ): string {
+        global $wpdb;
+        $types = self::post_types();
+        if ( $with_pages ) $types = array_values( array_unique( array_merge( $types, [ 'page' ] ) ) );
+        if ( $alias !== '' ) {
+            return $wpdb->prepare( '%i.post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', array_merge( [ $alias ], $types ) );
+        }
+        return $wpdb->prepare( 'post_type IN (' . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ')', $types );
+    }
+
+    /** True if a post falls outside the scope. */
+    public static function excludes( ?\WP_Post $post ): bool {
+        if ( ! $post || $post->post_status !== 'publish' ) return true;
+        if ( $post->post_type === Shop::TYPE && Shop::hidden( (int) $post->ID ) ) return true; // hidden from the shop (0.98.172)
+        return ! self::include_protected() && $post->post_password !== '';
+    }
+
+    /** Forget one post: metrics, cluster roles and the links it contributes. */
+    public static function forget_post( int $post_id ): void {
+        global $wpdb;
+        $wpdb->delete( $wpdb->prefix . 'clusterlane_post_metrics', [ 'post_id' => $post_id ], [ '%d' ] );
+        $wpdb->delete( $wpdb->prefix . 'clusterlane_cluster_post_class', [ 'post_id' => $post_id ], [ '%d' ] );
+        $wpdb->delete( $wpdb->prefix . 'clusterlane_links', [ 'source_id' => $post_id ], [ '%d' ] );
+    }
+
+    /**
+     * Remove stored data for every post now outside the scope (e.g. a post
+     * that got a password since the last scan). Runs at the start of a full
+     * scan; the next Compute Metrics then recalculates the clusters.
+     *
+     * @return int number of posts forgotten
+     */
+    public static function purge_out_of_scope(): int {
+        global $wpdb;
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $ids = $wpdb->get_col( $wpdb->prepare(
+            'SELECT m.post_id FROM %i m LEFT JOIN %i p ON p.ID = m.post_id WHERE p.ID IS NULL OR NOT ( ' . self::sql( 'p' ) . ' )',
+            $wpdb->prefix . 'clusterlane_post_metrics', $wpdb->posts
+        ) );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        foreach ( $ids as $id ) self::forget_post( (int) $id );
+        return count( $ids );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-seo-meta.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-seo-meta.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-seo-meta.php	(revision 3751027)
@@ -0,0 +1,347 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * SeoMeta — where an article's SEO title and meta description live.
+ *
+ * One reader for everything that needs them: the search-result preview in
+ * Article Inspector, the SEO score in the scanner and the "Missing / weak
+ * meta" filter in Articles to review. So they always agree.
+ *
+ * Sources (Settings → Content → SEO title & description):
+ *   auto   — the active SEO plugin (Yoast, Rank Math, SEOPress, AIOSEO,
+ *            The SEO Framework); with none active, the custom fields below.
+ *   custom — the custom fields, always (post meta keys or ACF field names;
+ *            ACF stores its values as post meta under the field name).
+ *
+ * Fallbacks when the field is empty (setting seo_fallback, on by default):
+ * title → post title, description → excerpt.
+ */
+final class SeoMeta {
+
+    /**
+     * AI review of title + description against the article (0.98.155, written
+     * by the premium seo/check): JSON {t, d, ti, di, hash, at} and the lower of
+     * the two scores (1–5) on its own, so lists can filter "poor match" in SQL.
+     * Cleared whenever Clusterlane writes the fields — the review was of the old text.
+     */
+    public const CHECK_META = '_clusterlane_seo_check';
+    public const CHECK_MIN  = '_clusterlane_seo_check_min';
+    /** A review score at or below this is a "poor match". */
+    public const CHECK_POOR = 2;
+
+    public static function clear_check( int $post_id ): void {
+        delete_post_meta( $post_id, self::CHECK_META );
+        delete_post_meta( $post_id, self::CHECK_MIN );
+    }
+
+    /**
+     * The original SEO title and description (0.98.197): the first time Clusterlane
+     * writes a field of an article, the value before is kept here — once, never
+     * overwritten by later writes — so "Restore original" always goes back to
+     * how the article was before Clusterlane. JSON {fields: {meta key: {existed,
+     * value}}, at, user}. Kept by Reset; removed only when the plugin is deleted.
+     */
+    public const ORIGINAL_META = '_clusterlane_seo_original';
+
+    /** @return array{fields: array<string, array{existed: bool, value: string}>, at: string, user: int}|null */
+    public static function original( int $post_id ): ?array {
+        $raw = get_post_meta( $post_id, self::ORIGINAL_META, true );
+        $d   = is_string( $raw ) && $raw !== '' ? json_decode( $raw, true ) : null;
+        return is_array( $d ) && ! empty( $d['fields'] ) && is_array( $d['fields'] ) ? $d : null;
+    }
+
+    /** Keep a field's value from before Clusterlane's first write to it. */
+    public static function keep_original( int $post_id, string $key, bool $existed, string $value ): void {
+        $o = self::original( $post_id ) ?? [ 'fields' => [], 'at' => gmdate( 'Y-m-d H:i:s' ), 'user' => get_current_user_id() ];
+        if ( isset( $o['fields'][ $key ] ) ) return; // the first value stays
+        $o['fields'][ $key ] = [ 'existed' => $existed, 'value' => $value ];
+        update_post_meta( $post_id, self::ORIGINAL_META, wp_slash( wp_json_encode( $o, JSON_UNESCAPED_UNICODE ) ) );
+    }
+
+    /**
+     * For the screens: the original title/description for the current fields and
+     * whether the article differs from it now (only then is there anything to restore).
+     *
+     * @return array{at: string, title: ?string, description: ?string, differs: bool}|null
+     */
+    public static function original_state( int $post_id ): ?array {
+        $o = self::original( $post_id );
+        if ( ! $o ) return null;
+        $src = self::source();
+        $out = [ 'at' => (string) ( $o['at'] ?? '' ), 'title' => null, 'description' => null, 'differs' => false ];
+        foreach ( [ 'title' => 'title', 'desc' => 'description' ] as $kind => $name ) {
+            $key = $src[ $kind ] ?? '';
+            if ( ! $key || ! isset( $o['fields'][ $key ] ) ) continue;
+            $out[ $name ] = (string) $o['fields'][ $key ]['value'];
+            if ( (string) get_post_meta( $post_id, $key, true ) !== $out[ $name ] ) $out['differs'] = true;
+        }
+        return $out['title'] === null && $out['description'] === null ? null : $out;
+    }
+
+    /** The stored review, or null. */
+    public static function check( int $post_id ): ?array {
+        $raw = get_post_meta( $post_id, self::CHECK_META, true );
+        $d   = is_string( $raw ) && $raw !== '' ? json_decode( $raw, true ) : null;
+        return is_array( $d ) && isset( $d['t'], $d['d'] ) ? $d : null;
+    }
+
+    /** Google shows roughly this much; used for the length status. */
+    public const TITLE_MAX = 60;
+    public const DESC_MIN  = 70;
+    public const DESC_MAX  = 160;
+
+    /** Known SEO plugins: detection + where they keep title/description. */
+    private static function plugins(): array {
+        return [
+            'yoast'    => [ 'label' => 'Yoast SEO',         'active' => fn() => defined( 'WPSEO_VERSION' ),              'title' => '_yoast_wpseo_title',     'desc' => '_yoast_wpseo_metadesc' ],
+            'rankmath' => [ 'label' => 'Rank Math',         'active' => fn() => class_exists( 'RankMath' ),              'title' => 'rank_math_title',        'desc' => 'rank_math_description' ],
+            'seopress' => [ 'label' => 'SEOPress',          'active' => fn() => defined( 'SEOPRESS_VERSION' ),           'title' => '_seopress_titles_title', 'desc' => '_seopress_titles_desc' ],
+            'aioseo'   => [ 'label' => 'All in One SEO',    'active' => fn() => defined( 'AIOSEO_VERSION' ),             'title' => null,                     'desc' => null ], // own table
+            'tsf'      => [ 'label' => 'The SEO Framework', 'active' => fn() => defined( 'THE_SEO_FRAMEWORK_VERSION' ), 'title' => '_genesis_title',         'desc' => '_genesis_description' ],
+        ];
+    }
+
+    /**
+     * The effective source for this site.
+     * @return array{id:string,label:string,title:?string,desc:?string,detected:bool}
+     */
+    /** Per-blog cache (static state survives switch_to_blog() — key by blog ID). */
+    private static array $cache = [];
+
+    public static function source(): array {
+        $blog = function_exists( 'get_current_blog_id' ) ? get_current_blog_id() : 0;
+        if ( isset( self::$cache[ $blog ] ) ) return self::$cache[ $blog ];
+
+        $s    = Settings::get_instance();
+        $mode = $s ? (string) $s->get( 'seo_source', 'auto' ) : 'auto';
+
+        if ( $mode !== 'custom' ) {
+            foreach ( self::plugins() as $id => $p ) {
+                if ( ( $p['active'] )() ) {
+                    return self::$cache[ $blog ] = [ 'id' => $id, 'label' => $p['label'], 'title' => $p['title'], 'desc' => $p['desc'], 'detected' => true ];
+                }
+            }
+        }
+        return self::$cache[ $blog ] = [
+            'id'       => 'custom',
+            'label'    => __( 'Custom fields', 'clusterlane' ),
+            'title'    => self::field_name( $s ? (string) $s->get( 'seo_title_field', 'seo_title' ) : 'seo_title' ),
+            'desc'     => self::field_name( $s ? (string) $s->get( 'seo_description_field', 'seo_description' ) : 'seo_description' ),
+            'detected' => false,
+        ];
+    }
+
+    /** Clear the per-request cache (after Settings are saved, and in tests). */
+    public static function reset(): void {
+        self::$cache = [];
+    }
+
+    /**
+     * May Clusterlane write the SEO fields (AI suggestions → Apply)?
+     * Only custom fields (post meta / ACF) — never an SEO plugin's storage:
+     * Yoast renders from its indexables table, AIOSEO has its own table, and
+     * a direct write there can leave the site showing stale text.
+     */
+    public static function can_write(): bool {
+        $s = Settings::get_instance();
+        return self::source()['id'] === 'custom' && ( $s ? (bool) $s->get( 'seo_ai_write', true ) : true );
+    }
+
+    /** Why Apply isn't offered, in plain words ('' when it is). */
+    public static function write_note(): string {
+        $src = self::source();
+        if ( $src['id'] !== 'custom' ) {
+            /* translators: %s: SEO plugin name */
+            return sprintf( __( 'Clusterlane doesn\'t write to %1$s\'s fields — copy the text into %2$s in the post editor.', 'clusterlane' ), $src['label'], $src['label'] );
+        }
+        if ( ! self::can_write() ) {
+            return __( 'Writing is turned off in Settings → Content — copy the text into the post editor.', 'clusterlane' );
+        }
+        return '';
+    }
+
+    public static function fallback(): bool {
+        $s = Settings::get_instance();
+        return $s ? (bool) $s->get( 'seo_fallback', true ) : true;
+    }
+
+    /**
+     * Brand suffix and how it is handled (Settings → Content → AI suggestions):
+     * ['text' => ' | Site', 'mode' => none|include|auto]. 'auto' = the site
+     * adds it to every title, so it counts toward the length.
+     */
+    public static function suffix(): array {
+        $s    = Settings::get_instance();
+        $mode = $s ? (string) $s->get( 'seo_ai_suffix_mode', 'none' ) : 'none';
+        $text = $s ? (string) $s->get( 'seo_ai_suffix', '' ) : '';
+        if ( $text === '' ) $mode = 'none';
+        return [ 'text' => $text, 'mode' => in_array( $mode, [ 'include', 'auto' ], true ) ? $mode : 'none' ];
+    }
+
+    /** Meta keys / ACF field names: letters, digits, _ and - only. */
+    public static function field_name( string $name ): string {
+        return (string) preg_replace( '/[^A-Za-z0-9_\-]/', '', $name );
+    }
+
+    /** Raw value of one field (ACF-aware), '' if missing. */
+    private static function field( int $post_id, ?string $key, string $kind ): string {
+        $src = self::source();
+        if ( $src['id'] === 'aioseo' ) {
+            global $wpdb;
+            $col   = $kind === 'title' ? 'title' : 'description';
+            $table = $wpdb->prefix . 'aioseo_posts';
+            $v = $wpdb->get_var( $wpdb->prepare( 'SELECT %i FROM %i WHERE post_id = %d', $col, $table, $post_id ) );
+            return is_string( $v ) ? $v : '';
+        }
+        if ( ! $key ) return '';
+        if ( $src['id'] === 'custom' && function_exists( 'get_field' ) ) {
+            $v = get_field( $key, $post_id, false ); // unformatted: plain text as stored
+            if ( is_string( $v ) || is_numeric( $v ) ) return (string) $v;
+        }
+        $v = get_post_meta( $post_id, $key, true );
+        return is_string( $v ) || is_numeric( $v ) ? (string) $v : '';
+    }
+
+    /** Plugin template variables (%%title%%, %title%, …) → text, where the plugin can do it. */
+    private static function expand( string $text, \WP_Post $post ): string {
+        if ( $text === '' ) return '';
+        $src = self::source()['id'];
+        if ( $src === 'yoast' && function_exists( 'wpseo_replace_vars' ) && str_contains( $text, '%%' ) ) {
+            $text = (string) wpseo_replace_vars( $text, $post );
+        } elseif ( $src === 'rankmath' && class_exists( '\RankMath\Helper' ) && method_exists( '\RankMath\Helper', 'replace_vars' ) && str_contains( $text, '%' ) ) {
+            $text = (string) \RankMath\Helper::replace_vars( $text, $post );
+        }
+        return $text;
+    }
+
+    private static function clean( string $text ): string {
+        $text = wp_strip_all_tags( $text );
+        // Decode until stable (content can be double-encoded: &amp;amp;).
+        for ( $i = 0; $i < 3; $i++ ) {
+            $d = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+            if ( $d === $text ) break;
+            $text = $d;
+        }
+        return trim( preg_replace( '/\s+/u', ' ', $text ) );
+    }
+
+    public static function len( string $text ): int {
+        return function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );
+    }
+
+    /** ok | missing | short | long */
+    public static function desc_status( string $desc ): string {
+        $n = self::len( $desc );
+        if ( $n === 0 )              return 'missing';
+        if ( $n < self::DESC_MIN )   return 'short';
+        if ( $n > self::DESC_MAX )   return 'long';
+        return 'ok';
+    }
+
+    /** ok | long */
+    public static function title_status( string $title ): string {
+        return self::len( $title ) > self::TITLE_MAX ? 'long' : 'ok';
+    }
+
+    /**
+     * Title + description for a post, with where each came from.
+     * @return array{title:string,title_from:string,description:string,description_from:string,source:string,source_id:string,title_status:string,description_status:string}
+     */
+    public static function get( \WP_Post $post ): array {
+        $src = self::source();
+
+        $title = self::clean( self::expand( self::field( $post->ID, $src['title'], 'title' ), $post ) );
+        $tfrom = $title !== '' ? 'field' : '';
+        $desc  = self::clean( self::expand( self::field( $post->ID, $src['desc'], 'desc' ), $post ) );
+        $dfrom = $desc !== '' ? 'field' : '';
+
+        if ( self::fallback() ) {
+            if ( $title === '' ) { $title = self::clean( get_the_title( $post ) ); $tfrom = 'post_title'; }
+            if ( $desc === '' && $post->post_excerpt !== '' ) { $desc = self::clean( $post->post_excerpt ); $dfrom = 'excerpt'; }
+        }
+
+        return [
+            'title'              => $title,
+            'title_from'         => $tfrom,
+            'description'        => $desc,
+            'description_from'   => $dfrom,
+            'source'             => $src['label'],
+            'source_id'          => $src['id'],
+            'title_status'       => $title === '' ? 'missing' : self::title_status( $title ),
+            'description_status' => self::desc_status( $desc ),
+        ];
+    }
+
+    /**
+     * SQL pieces for list queries: a LEFT JOIN and an expression for the
+     * effective description (fallback applied). $post_alias is the posts
+     * table alias, $id_col the post-ID column to join on.
+     * @return array{join:string,expr:string}
+     */
+    public static function sql_description( string $post_alias, string $id_col ): array {
+        global $wpdb;
+        $src = self::source();
+        if ( $src['id'] === 'aioseo' ) {
+            $join = "LEFT JOIN `{$wpdb->prefix}aioseo_posts` seo ON seo.post_id = $id_col";
+            $col  = 'seo.description';
+        } elseif ( $src['desc'] ) {
+            // One row per post even if the key is stored twice (get_post_meta reads the first).
+            $join = $wpdb->prepare( "LEFT JOIN ( SELECT post_id, MIN(meta_value) AS meta_value FROM `{$wpdb->postmeta}` WHERE meta_key = %s GROUP BY post_id ) seo ON seo.post_id = $id_col", $src['desc'] ); // phpcs:ignore
+            $col  = 'seo.meta_value';
+        } else {
+            return [ 'join' => '', 'expr' => self::fallback() ? "TRIM({$post_alias}.post_excerpt)" : "''" ];
+        }
+        $expr = self::fallback()
+            ? "COALESCE(NULLIF(TRIM($col),''), TRIM({$post_alias}.post_excerpt), '')"
+            : "COALESCE(TRIM($col), '')";
+        return [ 'join' => $join, 'expr' => $expr ];
+    }
+
+    /**
+     * SQL pieces for the effective SEO title (fallback: the post title), like
+     * sql_description(). Joined as `seot`, so both can be used in one query.
+     * @return array{join:string,expr:string}
+     */
+    public static function sql_title( string $post_alias, string $id_col ): array {
+        global $wpdb;
+        $src = self::source();
+        if ( $src['id'] === 'aioseo' ) {
+            $join = "LEFT JOIN `{$wpdb->prefix}aioseo_posts` seot ON seot.post_id = $id_col";
+            $col  = 'seot.title';
+        } elseif ( $src['title'] ) {
+            $join = $wpdb->prepare( "LEFT JOIN ( SELECT post_id, MIN(meta_value) AS meta_value FROM `{$wpdb->postmeta}` WHERE meta_key = %s GROUP BY post_id ) seot ON seot.post_id = $id_col", $src['title'] ); // phpcs:ignore
+            $col  = 'seot.meta_value';
+        } else {
+            return [ 'join' => '', 'expr' => self::fallback() ? "TRIM({$post_alias}.post_title)" : "''" ];
+        }
+        $expr = self::fallback()
+            ? "COALESCE(NULLIF(TRIM($col),''), TRIM({$post_alias}.post_title), '')"
+            : "COALESCE(TRIM($col), '')";
+        return [ 'join' => $join, 'expr' => $expr ];
+    }
+
+    /** SQL condition on the title expression: longer than search engines show. */
+    public static function sql_title_long( string $expr ): string {
+        return sprintf( 'CHAR_LENGTH(%1$s) > %2$d', $expr, self::TITLE_MAX );
+    }
+
+    /**
+     * SQL condition on the description expression: missing | weak.
+     * Weak means a description that exists but is too short or too long (0.98.185:
+     * an empty one used to count as both, so the filters read "No description 91 ·
+     * Weak description 91" and the report's "good" share came out too low).
+     */
+    public static function sql_status( string $expr, string $which ): string {
+        if ( $which === 'missing' ) return "CHAR_LENGTH($expr) = 0";
+        return sprintf( '(CHAR_LENGTH(%1$s) > 0 AND (CHAR_LENGTH(%1$s) < %2$d OR CHAR_LENGTH(%1$s) > %3$d))', $expr, self::DESC_MIN, self::DESC_MAX );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-settings.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-settings.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-settings.php	(revision 3751027)
@@ -0,0 +1,466 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Settings
+ *
+ * Single source of truth for all Clusterlane configuration.
+ * Stored as a single serialized array under option key 'clusterlane_settings'.
+ *
+ * Defaults are defined here so the plugin works out of the box
+ * without requiring the user to configure anything first.
+ */
+class Settings {
+
+    const OPTION_KEY = 'clusterlane_settings';
+
+    /** @var self|null */
+    private static ?self $instance = null;
+
+    /** @var array<string, mixed> */
+    private array $data = [];
+
+    public static function get_instance(): ?self {
+        return self::$instance;
+    }
+
+    public function __construct() {
+        self::$instance = $this;
+        $stored         = get_option( self::OPTION_KEY, [] );
+        $this->data     = wp_parse_args( $stored, $this->defaults() );
+    }
+
+    /* ------------------------------------------------------------------
+       Read
+    ------------------------------------------------------------------ */
+
+    public function get( string $key, mixed $fallback = null ): mixed {
+        return $this->data[ $key ] ?? $fallback;
+    }
+
+    public function all(): array {
+        return $this->data;
+    }
+
+    /* ------------------------------------------------------------------
+       Write
+    ------------------------------------------------------------------ */
+
+    public function set( string $key, mixed $value ): void {
+        $this->data[ $key ] = $value;
+        update_option( self::OPTION_KEY, $this->data, false );
+    }
+
+    public function save( array $data ): void {
+        $this->data = wp_parse_args( $data, $this->defaults() );
+        update_option( self::OPTION_KEY, $this->data, false );
+    }
+
+    /* ------------------------------------------------------------------
+       Convenience getters
+    ------------------------------------------------------------------ */
+
+    /* ------------------------------------------------------------------
+       AI — effective configuration
+       Every AI caller uses these. On multisite they resolve between this
+       site's own key and the network key (see NetworkAi); on single site
+       they are simply this site's settings.
+    ------------------------------------------------------------------ */
+
+    /** Where the AI configuration comes from: 'site', 'network' or 'none'. */
+    public function ai_source(): string {
+        $site = $this->has_site_ai_api_key();
+        if ( class_exists( NetworkAi::class ) && NetworkAi::enabled() ) { // NetworkAi is premium
+            return NetworkAi::source_for( $site );
+        }
+        return $site ? 'site' : 'none';
+    }
+
+    public function ai_provider(): string {
+        return $this->ai_source() === 'network' ? NetworkAi::provider() : $this->site_ai_provider();
+    }
+
+    public function ai_model(): string {
+        return $this->ai_source() === 'network' ? NetworkAi::model() : $this->site_ai_model();
+    }
+
+    /** Decrypted effective API key, or '' if none. */
+    public function ai_api_key(): string {
+        return match ( $this->ai_source() ) {
+            'site'    => $this->site_ai_api_key(),
+            'network' => NetworkAi::api_key(),
+            default   => '',
+        };
+    }
+
+    /** True if an effective key exists (without exposing it). */
+    public function has_ai_api_key(): bool {
+        return $this->ai_source() !== 'none';
+    }
+
+    /* ------------------------------------------------------------------
+       AI — this site's own settings (Settings page reads/writes these)
+    ------------------------------------------------------------------ */
+
+    public function site_ai_provider(): string { return $this->get( 'ai_provider', 'openai' ); }
+    public function site_ai_model(): string    { return $this->get( 'ai_model',    'gpt-4o-mini' ); }
+
+    /** This site's own decrypted key, or '' if none stored. */
+    public function site_ai_api_key(): string {
+        return self::decrypt_stored( (string) $this->get( 'ai_api_key', '' ) );
+    }
+
+    /** True if this site stores its own key. */
+    public function has_site_ai_api_key(): bool {
+        return $this->get( 'ai_api_key', '' ) !== '';
+    }
+
+    /**
+     * A site key is stored but can no longer be decrypted — WordPress's AUTH_KEY
+     * changed after it was saved (new salts, a clone or move with another
+     * wp-config.php). has_site_ai_api_key() is still true then (0.98.174).
+     */
+    public function site_ai_key_unreadable(): bool {
+        return $this->has_site_ai_api_key() && $this->site_ai_api_key() === '';
+    }
+
+    /** The key AI calls would use (site or network) is stored but can't be decrypted. */
+    public function ai_key_unreadable(): bool {
+        return match ( $this->ai_source() ) {
+            'site'    => $this->site_ai_key_unreadable(),
+            'network' => NetworkAi::has_key() && NetworkAi::api_key() === '',
+            default   => false,
+        };
+    }
+
+    /** Stored value ("cci_enc:…" or legacy plain text) → plain key, or ''. */
+    public static function decrypt_stored( string $stored ): string {
+        if ( $stored === '' ) return '';
+        // Legacy: if not prefixed, it's a plain-text key from before encryption.
+        if ( strncmp( $stored, 'cci_enc:', 8 ) !== 0 ) return $stored;
+        return self::decrypt_key( substr( $stored, 8 ) ) ?: '';
+    }
+
+    /** Plain key → value to store ("cci_enc:…", or plain if OpenSSL is missing). */
+    public static function encrypt_for_storage( string $key ): string {
+        $encrypted = self::encrypt_key( $key );
+        return $encrypted !== false ? 'cci_enc:' . $encrypted : $key;
+    }
+
+    /** Encrypt and store the API key. */
+    public function save_ai_api_key( string $key ): void {
+        if ( $key === '' ) return;
+        $data      = $this->all();
+        $data['ai_api_key'] = self::encrypt_for_storage( $key );
+        $this->data = $data; // keep this request's copy in sync (a later set() would drop the key)
+        update_option( 'clusterlane_settings', $data );
+    }
+
+    private static function get_encryption_key(): string {
+        // Derive a 32-byte key from WP's AUTH_KEY so it never touches the DB.
+        $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'clusterlane-fallback-salt-change-me';
+        return substr( hash( 'sha256', $salt . 'cci_api_key', true ), 0, 32 );
+    }
+
+    private static function encrypt_key( string $plaintext ): string|false {
+        if ( ! function_exists( 'openssl_encrypt' ) ) return false;
+        $iv  = random_bytes( 16 );
+        $enc = openssl_encrypt( $plaintext, 'aes-256-cbc', self::get_encryption_key(), 0, $iv );
+        if ( $enc === false ) return false;
+        return base64_encode( $iv . base64_decode( $enc ) );
+    }
+
+    private static function decrypt_key( string $ciphertext ): string|false {
+        if ( ! function_exists( 'openssl_decrypt' ) ) return false;
+        $raw = base64_decode( $ciphertext, true );
+        if ( $raw === false || strlen( $raw ) < 17 ) return false;
+        $iv  = substr( $raw, 0, 16 );
+        $enc = base64_encode( substr( $raw, 16 ) );
+        return openssl_decrypt( $enc, 'aes-256-cbc', self::get_encryption_key(), 0, $iv ) ?: false;
+    }
+    /** Custom gallery shortcode names for image counting (one per line). */
+    public function gallery_shortcodes(): array {
+        $raw = $this->get( 'gallery_shortcodes', '' );
+        if ( ! $raw ) return [];
+        return array_values( array_filter( array_map(
+            'trim',
+            preg_split( '/[\n,]+/', $raw )
+        ) ) );
+    }
+
+    /** How to truncate article titles in Clusterlane's own views (never touches DB). */
+    public function title_truncation(): string {
+        return $this->get( 'title_truncation', 'none' );
+    }
+
+    /** Custom regex pattern for title truncation (used when title_truncation = 'custom'). */
+    public function title_truncation_custom(): string {
+        return $this->get( 'title_truncation_custom', '' );
+    }
+
+    public function language(): string      { return $this->get( 'language',    'en' ); }
+
+    /** Custom stop words for Text Match Scanner, as an array of lowercase strings. */
+    public function tms_stop_words(): array {
+        $raw = self::heal_entities( (string) $this->get( 'tms_stop_words', '' ) );
+        if ( ! $raw ) return [];
+        return array_values( array_filter( array_map(
+            'trim',
+            preg_split( '/[\n,]+/', $raw )
+        ) ) );
+    }
+    public function post_types(): array     { return $this->get( 'post_types',  ['post'] ); }
+
+    /**
+     * Scanned post types whose published pages are read as well (0.98.177): links
+     * and text the theme adds through its templates — lists, cards, related
+     * articles — which are not in the article text. Off by default.
+     */
+    public function rendered_types(): array {
+        return array_values( array_intersect(
+            array_map( 'sanitize_key', (array) $this->get( 'rendered_types', [] ) ),
+            array_map( 'sanitize_key', (array) $this->post_types() )
+        ) );
+    }
+
+    /**
+     * Terms of any other taxonomy chosen as clusters (0.98.177) — attraction
+     * categories, regions, genres … — as taxonomy → term IDs. Categories, tags
+     * and product categories keep their own settings.
+     * @return array<string, int[]>
+     */
+    public function enabled_terms(): array {
+        $out = [];
+        foreach ( (array) $this->get( 'enabled_terms', [] ) as $tax => $ids ) {
+            $tax = sanitize_key( (string) $tax );
+            if ( $tax === '' || in_array( $tax, [ 'category', 'post_tag', 'product_cat', 'custom' ], true ) ) continue;
+            $out[ $tax ] = array_values( array_unique( array_filter( array_map( 'intval', (array) $ids ) ) ) );
+        }
+        return $out;
+    }
+
+    /** User-defined "See also" / link-insertion HTML templates. Each: {id, name, html}. */
+    public function link_templates(): array { return $this->get( 'link_templates', [] ); }
+
+    public function taxonomies(): array {
+        return $this->get( 'taxonomies', [ 'category' ] );
+    }
+
+    public function taxonomy(): string {
+        // Legacy single-taxonomy accessor — returns first active taxonomy.
+        $taxs = $this->taxonomies();
+        return $taxs[0] ?? 'category';
+    }
+
+    public function min_term_count(): int {
+        return (int) $this->get( 'min_term_count', 1 );
+    }
+
+    /** Cluster keys (term slugs) that are disabled by the user. */
+    public function disabled_clusters(): array {
+        return $this->get( 'disabled_clusters', [] );
+    }
+
+    /** Individual category term IDs explicitly chosen as clusters on the Cluster Admin page. */
+    public function enabled_categories(): array {
+        return array_map( 'intval', (array) $this->get( 'enabled_categories', [] ) );
+    }
+
+    /** Product categories (WooCommerce product_cat term IDs) chosen as clusters on the Cluster Admin page (0.98.172). */
+    public function enabled_product_cats(): array {
+        return array_map( 'intval', (array) $this->get( 'enabled_product_cats', [] ) );
+    }
+
+    /** Individual tag term IDs explicitly chosen as clusters on the Cluster Admin page. */
+    public function enabled_tags(): array {
+        return array_map( 'intval', (array) $this->get( 'enabled_tags', [] ) );
+    }
+
+    /**
+     * Whether the user has explicitly saved a category/tag selection on
+     * the Cluster Admin page at least once. Before that, category/tag
+     * clustering falls back to the taxonomy-wide min_term_count behavior
+     * so the plugin still works out of the box with zero configuration.
+     */
+    public function cluster_terms_configured(): bool {
+        return (bool) $this->get( 'cluster_terms_configured', false );
+    }
+
+    /** CSS selectors whose content the Text Match Scanner should ignore. */
+    public function tms_ignore_selectors(): array {
+        return $this->get( 'tms_ignore_selectors', ['.info-list', '.visitor-info'] );
+    }
+
+    /* ------------------------------------------------------------------
+       AI prompts — overrideable per-site
+    ------------------------------------------------------------------ */
+
+    public function prompt( string $key ): string {
+        $prompts = $this->get( 'prompts', [] );
+        return isset( $prompts[ $key ] ) ? self::heal_entities( (string) $prompts[ $key ] ) : ( $this->default_prompts()[ $key ] ?? '' );
+    }
+
+    /**
+     * Plain text from a settings textarea (prompts, stop words, instructions).
+     * Not sanitize_textarea_field(): for text with a lone "<" (a prompt's
+     * "<=155 chars", a regex "(?<!…)") it HTML-encodes everything after it,
+     * so each save changed the text a little more (fixed in 0.98.128). These
+     * values are never output as HTML — they are escaped where shown.
+     */
+    public static function clean_text( string $v, int $max = 8000 ): string {
+        $v = wp_check_invalid_utf8( str_replace( [ "\r\n", "\r" ], "\n", $v ) );
+        $v = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $v ) ?? '';
+        return mb_substr( trim( $v ), 0, $max );
+    }
+
+    /** Undo the HTML entities the old sanitizer wrote into saved text (&quot; &lt; …). */
+    public static function heal_entities( string $v ): string {
+        for ( $i = 0; $i < 3 && preg_match( '/&(?:quot|lt|gt|amp|#0?39|apos);/', $v ); $i++ ) {
+            $v = html_entity_decode( $v, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+        }
+        return $v;
+    }
+
+    /* ------------------------------------------------------------------
+       Defaults
+    ------------------------------------------------------------------ */
+
+    private function defaults(): array {
+        return [
+            'ai_provider'          => 'openai',
+            'ai_api_key'           => '',
+            'ai_model'             => 'gpt-4o-mini',
+            'language'             => 'en',
+            'post_types'           => ['post'],
+            'taxonomies'           => ['category'],
+            'min_term_count'       => 1,
+            'batch_size'           => 25,
+            'title_truncation'     => 'none',
+            'title_truncation_custom' => '',
+            // SEO title & description (SeoMeta): auto = active SEO plugin, else these fields.
+            'seo_source'            => 'auto',
+            'seo_title_field'       => 'seo_title',
+            'seo_description_field' => 'seo_description',
+            'seo_fallback'          => true,
+            'show_thumbnails'       => true,     // small featured images in article lists
+            // AI suggestions for SEO title/description (Pro).
+            'seo_ai_suffix'         => '',
+            'seo_ai_suffix_mode'    => 'none',   // none | include | auto
+            'seo_ai_language'       => '',       // '' = same as the article
+            'seo_ai_instructions'   => '',
+            'seo_ai_write'          => true,     // custom fields only (SeoMeta::can_write)
+            'gallery_shortcodes'   => '',
+            'disabled_clusters'    => [],
+            'enabled_categories'      => [],
+            'enabled_tags'            => [],
+            'enabled_product_cats'    => [],
+            'enabled_terms'           => [],     // other taxonomies: taxonomy => term IDs (0.98.177)
+            'rendered_types'          => [],     // post types whose published pages are read (0.98.177)
+            'rendered_cached'         => false,  // read them as the page cache serves them (0.98.178)
+            'enabled_hubs'            => [],     // post types whose pages are clusters (0.98.180)
+            'cluster_terms_configured'=> false,
+            'tms_ignore_selectors' => ['.info-list', '.visitor-info'],
+            'prompts'              => $this->default_prompts(),
+            'link_templates'       => $this->default_link_templates(),
+        ];
+    }
+
+    /** Public static accessor so the Settings page can read defaults without an instance. */
+    public static function default_prompts_public(): array {
+        return ( new self() )->default_prompts();
+    }
+
+    private function default_prompts(): array {
+        return [
+
+            'link_style_suggest' =>
+                'You are a web designer. Suggest 3 distinct color/style variants for a small ' .
+                '"link card" — a short highlighted box inserted into article text, with an optional ' .
+                'bold heading line and a sentence containing a link. Base the palette on the ' .
+                'description given. Each variant needs a heading (a short label like "Worth exploring", ' .
+                'or empty string for no heading), a border_color and background_color that are ' .
+                'harmonious and readable together, a text_color with strong contrast against the ' .
+                'background, whether to show a left border accent (has_border), and a border_radius ' .
+                'in pixels (0-16). All colors as hex strings like "#2271b1". ' .
+                'Return ONLY valid JSON: {"variants":[{"heading":"text","has_border":true,' .
+                '"border_color":"#hex","background_color":"#hex","text_color":"#hex","border_radius":4}]}',
+
+            'cluster_detect' =>
+                'You are a content strategist. The site already groups its articles by category and tag. ' .
+                'Find CROSS-CUTTING themes those groups miss — reader situations such as a season or occasion, ' .
+                'an audience, a budget, an area or a day itinerary — and group articles from different existing ' .
+                'clusters into new clusters a reader would follow. ' .
+                'Return ONLY valid JSON in this exact shape: ' .
+                '{"clusters":[{"name":"Cluster name","slug":"cluster-slug","post_ids":[1,2,3],"rationale":"Why these belong together"}]}',
+
+            'gap_analysis' =>
+                'You are a content strategist. The following articles form a content cluster. ' .
+                'Analyse the cluster and identify: ' .
+                '1. Topics that are under-covered or missing entirely. ' .
+                '2. Three specific new article ideas that would strengthen the cluster. ' .
+                'Be concrete and specific to the subject matter. ' .
+                'Return ONLY valid JSON: ' .
+                '{"analysis":"Your analysis","suggestions":[{"title":"Article title","rationale":"Why it helps"}]}',
+
+            'gap_brief' =>
+                'You are a senior content strategist writing a brief for a NEW article that will strengthen an existing content cluster. ' .
+                'You are given the cluster name, the proposed article title and rationale, and the existing cluster articles with their IDs, URLs, roles and excerpts. ' .
+                'Write a practical brief a human writer (or a writing assistant) can execute. ' .
+                'Rules: sections must be specific to the subject, not generic; link_to must ONLY use post IDs from the list; ' .
+                'prefer pillar/support articles for link_from; keep the brief concise. ' .
+                'Return ONLY valid JSON: ' .
+                '{"purpose":"1-2 sentences on what this article adds to the cluster",' .
+                '"target_keyword":"primary search phrase",' .
+                '"meta_description":"<=155 chars",' .
+                '"sections":[{"heading":"H2 heading","notes":"1-2 sentences on what this section should cover"}],' .
+                '"link_to":[{"post_id":123,"anchor":"suggested anchor text","why":"short reason"}],' .
+                '"link_from":[{"post_id":123,"why":"short reason"}]}',
+
+            'link_suggest' =>
+                'You are an SEO specialist. I want to add an internal link from Article A to Article B. ' .
+                'Read the excerpt from Article A and suggest 3 different ways to naturally include ' .
+                'a link to Article B — varying anchor text, surrounding context, and position in the text. ' .
+                'The anchor_text must not repeat words that already appear elsewhere in the context ' .
+                'sentence — it should read naturally once substituted for [LINK], with no phrase duplicated. ' .
+                'Return ONLY valid JSON: ' .
+                '{"suggestions":[{"anchor_text":"text","context":"surrounding sentence with [LINK] placeholder","position":"beginning|middle|end"}]}',
+        ];
+    }
+
+    /**
+     * Starter "See also" card designs, shown out of the box so a fresh
+     * install isn't just a blank textarea. Matches the exact style format
+     * the Settings page's Quick Style panel produces (same placeholders),
+     * so any of these can be reopened there and tweaked normally. Also
+     * offered as a one-click "✨ Add starter templates" button on the
+     * Settings page itself, for sites that already have link_templates
+     * saved — this code default only applies to brand-new installs, since
+     * an existing site's saved settings always win over these.
+     */
+    private function default_link_templates(): array {
+        return [
+            [
+                'id'   => 'tpl_worth_exploring',
+                'name' => '💡 Worth exploring',
+                'html' => '<blockquote style="margin:1.5em 0;padding:1em 1.25em;background:#f0f6fc;color:#1e1e1e;border-radius:4px;border-left:4px solid #2271b1;"><strong>Worth exploring</strong><br>{sentence}</blockquote>',
+            ],
+            [
+                'id'   => 'tpl_also_worth_reading',
+                'name' => '📚 Also worth reading',
+                'html' => '<blockquote style="margin:1.5em 0;padding:1em 1.25em;background:#f1f8f2;color:#1e1e1e;border-radius:4px;border-left:4px solid #2e7d32;"><strong>Also worth reading</strong><br>{sentence}</blockquote>',
+            ],
+            [
+                'id'   => 'tpl_nearby',
+                'name' => '📍 Nearby',
+                'html' => '<blockquote style="margin:1.5em 0;padding:1em 1.25em;background:#fdf3ea;color:#1e1e1e;border-radius:4px;border-left:4px solid #c05c00;"><strong>Nearby</strong><br>{sentence}</blockquote>',
+            ],
+            [
+                'id'   => 'tpl_related',
+                'name' => '➜ Related',
+                'html' => '<blockquote style="margin:1.5em 0;padding:1em 1.25em;background:#f8fafc;color:#1e1e1e;border-radius:8px;border-left:4px solid #94a3b8;">{sentence}</blockquote>',
+            ],
+        ];
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-shop.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-shop.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-shop.php	(revision 3751027)
@@ -0,0 +1,71 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Shop (0.98.172) — WooCommerce products as Clusterlane content.
+ *
+ * Nothing here does anything unless WooCommerce is active. With it:
+ *   - product categories (product_cat) can be chosen as clusters in Cluster
+ *     Admin, which also adds products to the scanned post types;
+ *   - products hidden from the shop (visibility "Search results only" or
+ *     "Hidden", i.e. the product_visibility term exclude-from-catalog) are
+ *     out of scope everywhere: no scores, no cluster membership, never a
+ *     link target — through Scope, like password-protected posts;
+ *   - variations (product_variation) are never scanned: they have no page;
+ *   - products get their own score (Scanner): a good product page is a clear
+ *     description, a short description and images, and articles linking to
+ *     it — not 800 words with H2 headings or links out.
+ *
+ * WooCommerce keeps a product's long description in post_content and its
+ * short description in post_excerpt; both are read as the product's text.
+ */
+final class Shop {
+
+    public const TYPE     = 'product';
+    public const TAXONOMY = 'product_cat';
+
+    public static function active(): bool {
+        return class_exists( 'WooCommerce' ) && post_type_exists( self::TYPE ) && taxonomy_exists( self::TAXONOMY );
+    }
+
+    public static function is_product( ?\WP_Post $post ): bool {
+        return $post && $post->post_type === self::TYPE && self::active();
+    }
+
+    /** Products are among the scanned post types (Cluster Admin adds them when a product category is chosen). */
+    public static function in_scope(): bool {
+        return self::active() && in_array( self::TYPE, Scope::post_types(), true );
+    }
+
+    /** True when WooCommerce keeps this product out of the shop's catalogue. */
+    public static function hidden( int $post_id ): bool {
+        return self::active() && has_term( 'exclude-from-catalog', 'product_visibility', $post_id );
+    }
+
+    /**
+     * SQL (starting with " AND ") that leaves hidden products out, for a posts
+     * table alias ('' = the posts table by its own name). '' when not a shop.
+     */
+    public static function hidden_sql( string $alias = '' ): string {
+        if ( ! self::in_scope() ) return '';
+        global $wpdb;
+        $a = $alias !== '' ? $alias : $wpdb->posts;
+        return " AND NOT ( {$a}.post_type = 'product' AND EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} shtr
+            INNER JOIN {$wpdb->term_taxonomy} shtt ON shtt.term_taxonomy_id = shtr.term_taxonomy_id AND shtt.taxonomy = 'product_visibility'
+            INNER JOIN {$wpdb->terms} sht ON sht.term_id = shtt.term_id AND sht.slug = 'exclude-from-catalog'
+            WHERE shtr.object_id = {$a}.ID ) )";
+    }
+
+    /** Short description (post_excerpt) of a product, as plain text. */
+    public static function short_description( \WP_Post $post ): string {
+        return trim( wp_strip_all_tags( (string) $post->post_excerpt ) );
+    }
+
+    /** Images in a product's gallery (_product_image_gallery), not counting the main image. */
+    public static function gallery_count( int $post_id ): int {
+        $g = (string) get_post_meta( $post_id, '_product_image_gallery', true );
+        return $g === '' ? 0 : count( array_filter( explode( ',', $g ) ) );
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-text-match.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-text-match.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-text-match.php	(revision 3751027)
@@ -0,0 +1,859 @@
+<?php
+namespace Clusterlane\Core;
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+// phpcs:disable WordPress.DB.DirectDatabaseQuery
+// -- Clusterlane's own tables and set-based reads of posts/terms that no WordPress API covers.
+//    Every query goes through $wpdb->prepare(): values and ID lists as placeholders, table and
+//    column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared
+//    where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins).
+
+/**
+ * TextMatch
+ *
+ * Scans post content for unlinked mentions of other post titles.
+ * Ported from museumlite-intelligence text-match-engine.php with
+ * improvements: OOP interface, slug cache integration, mb_-safe
+ * string handling throughout.
+ */
+class TextMatch {
+
+    /* ------------------------------------------------------------------
+       Constants
+    ------------------------------------------------------------------ */
+
+    const MIN_KEYWORD_LEN   = 4;
+    const PROXIMITY_WINDOW  = 200;
+    const SNIPPET_CONTEXT   = 80;
+
+    const STOP_WORDS = [
+        'the', 'and', 'for', 'with', 'from', 'that', 'this', 'have', 'will',
+        'city', 'town', 'area', 'park', 'tour', 'food', 'wine', 'beer', 'art',
+        'film', 'life', 'home', 'land', 'lake', 'view', 'walk', 'time', 'year',
+        'day', 'old', 'new', 'best', 'top', 'map', 'sea', 'way', 'use',
+        'must', 'also', 'more', 'most', 'some', 'into', 'been', 'when',
+    ];
+
+    /** Extra stop words added at runtime (from Settings). */
+    private array $extra_stop_words = [];
+
+    /** Extra stop phrases (multi-word plain entries) — matched against whole titles. */
+    private array $extra_stop_phrases = [];
+
+    /** Extra stop word regex patterns (entries starting with /). */
+    private array $extra_stop_patterns = [];
+
+    /** Keywords per title — built once per request, not once per source article (0.98.170). */
+    private array $kw_cache = [];
+
+    public function set_extra_stop_words( array $entries ): void {
+        $this->kw_cache = [];
+        $this->cidx     = [];
+        $this->extra_stop_words   = [];
+        $this->extra_stop_phrases = [];
+        $this->extra_stop_patterns = [];
+        foreach ( $entries as $entry ) {
+            $entry = trim( $entry );
+            if ( $entry === '' ) continue;
+            // Detect regex: must start and end with / (optionally with flags like /i)
+            if ( preg_match( '#^/.+/[imsxuADU]*$#', $entry ) ) {
+                $this->extra_stop_patterns[] = $entry;
+            } else {
+                $normalized = strtolower( $entry );
+                $parts      = preg_split( '/\s+/', $normalized, -1, PREG_SPLIT_NO_EMPTY );
+                if ( count( $parts ) > 1 ) {
+                    // Multi-word entry → treat as a phrase matched against whole titles.
+                    $this->extra_stop_phrases[] = $normalized;
+                } else {
+                    // Single word → classic stop-word behaviour.
+                    if ( $parts[0] !== '' ) $this->extra_stop_words[] = $parts[0];
+                }
+            }
+        }
+    }
+
+    private function is_stop_word( string $word ): bool {
+        $w = strtolower( $word );
+        if ( in_array( $w, self::STOP_WORDS, true ) ) return true;
+        if ( in_array( $w, $this->extra_stop_words, true ) ) return true;
+        foreach ( $this->extra_stop_patterns as $pattern ) {
+            if ( @preg_match( $pattern, $word ) ) return true;
+        }
+        return false;
+    }
+
+    /**
+     * Returns true if $title matches a multi-word stop phrase (case-insensitive).
+     * The stripped (pre-separator) title is passed in, so "Royal Stockholm: ..."
+     * is already reduced to "Royal Stockholm" before this check.
+     */
+    private function is_stop_phrase( string $title ): bool {
+        $lower = strtolower( $title );
+        foreach ( $this->extra_stop_phrases as $phrase ) {
+            if ( $lower === $phrase ) return true;
+        }
+        return false;
+    }
+
+    /**
+     * Check whether an entire title should be skipped based on regex patterns.
+     * Plain stop words only suppress individual words; patterns can suppress whole titles.
+     */
+    private function title_matches_stop_pattern( string $title ): bool {
+        foreach ( $this->extra_stop_patterns as $pattern ) {
+            if ( @preg_match( $pattern, $title ) ) return true;
+        }
+        return false;
+    }
+
+    /* ------------------------------------------------------------------
+       Public API
+    ------------------------------------------------------------------ */
+
+    /**
+     * Scan a single post for unlinked mentions of other post titles.
+     *
+     * @param  int $source_id   Post to scan.
+     * @param  int $batch_size  Max candidates per call.
+     * @param  int $offset      Candidate offset for pagination.
+     * @return array { matches, total_candidates, scanned, offset }
+     */
+    public function scan( int $source_id, int $batch_size = 100, int $offset = 0 ): array {
+        $post = get_post( $source_id );
+        if ( ! $post || Scope::excludes( $post ) ) { // unpublished, protected, or a product hidden from the shop
+            return [ 'matches' => [], 'total_candidates' => 0, 'scanned' => 0, 'offset' => $offset ];
+        }
+
+        [ $content, $content_text, $free_text ] = self::prepare( (string) $post->post_content );
+
+        // Build set of already-linked targets as "type:id" keys.
+        // extract_linked_ids() now returns strings like "post:42" or "term:7"
+        // so that post IDs and term IDs (independent sequences in WordPress)
+        // never collide when we check whether a candidate has been linked.
+        $already_linked_keys = $this->extract_linked_ids( $content );
+        $already_linked_keys[] = 'post:' . $source_id; // never link to self
+
+        // Also pull from clusterlane_links for authoritative, scanner-backed coverage.
+        // Filter on target_type='post' so term IDs don't pollute this set.
+        global $wpdb;
+        $db_post_ids = $wpdb->get_col( $wpdb->prepare(
+            "SELECT target_id FROM {$wpdb->prefix}clusterlane_links WHERE source_id = %d AND target_type = 'post' AND link_type = 'inline'", // a pillar box or a theme card is no reason to skip a link in the text
+            $source_id
+        ) );
+        foreach ( $db_post_ids as $pid ) {
+            $already_linked_keys[] = 'post:' . (int) $pid;
+        }
+        $already_linked_keys = array_unique( $already_linked_keys );
+
+        // Candidates (multilingual sites: the source's own language, 0.98.168), with an index
+        // from each title's first word to its position — built once per request (0.98.173).
+        $idx       = $this->candidate_index( Lang::of( $source_id ) );
+        $all_posts = $idx['list'];
+        $total     = count( $all_posts );
+        $end       = min( $total, $offset + max( 0, $batch_size ) );
+        $linked    = [];
+        foreach ( $already_linked_keys as $k ) if ( strncmp( $k, 'post:', 5 ) === 0 ) $linked[ (int) substr( $k, 5 ) ] = true;
+
+        $matches = [];
+        // Speed (0.98.170): the text in lower case, to skip titles that do not occur at all
+        // before running the exact pattern, and the article's link texts, read once.
+        $free_lc = self::lc( $free_text );
+        $anchors = self::anchor_texts( $content );
+        // Word index (0.98.173): every run of letters in the text, as the pattern's own
+        // boundary class sees it — a hash lookup per title instead of a string search
+        // through the whole text (a 15,000-page shop has 15,000 titles to try).
+        $words   = self::word_set( $free_lc );
+
+        // Only titles whose first word is in the text (or that start with no letter) can match;
+        // look the text's words up in the index instead of trying every title. Same order and
+        // batches as trying them all: positions in [offset, end), ascending.
+        $pick = [];
+        foreach ( $idx['any'] as $i ) if ( $i >= $offset && $i < $end ) $pick[] = $i;
+        foreach ( $words as $w => $_ ) {
+            if ( ! isset( $idx['tok'][ $w ] ) ) continue;
+            foreach ( $idx['tok'][ $w ] as $i ) if ( $i >= $offset && $i < $end ) $pick[] = $i;
+        }
+        sort( $pick );
+
+        foreach ( $pick as $i ) {
+            $candidate = $all_posts[ $i ];
+            // Skip if already linked (by DB record or href found in content).
+            if ( isset( $linked[ (int) $candidate->ID ] ) ) continue;
+
+            // Skip if the candidate's title already appears as anchor text
+            // anywhere in the content — handles term-archive links and
+            // partial title matches (e.g. anchor is a prefix of the post title).
+            $keyword = $idx['kw'][ $i ];
+            foreach ( $keyword['toks'] ?? [] as $t ) if ( ! isset( $words[ $t ] ) ) continue 2; // a word of the name is not in the text
+            if ( ! self::may_occur( $free_lc, $keyword ) ) continue; // cheap test first — the pattern would not match either
+
+            if ( $this->title_already_linked( $candidate->post_title, $content, $anchors ) ) continue;
+
+            $hit = $this->find_match( $content_text, $free_text, $keyword );
+            if ( ! $hit ) continue;
+
+            $matches[] = [
+                'source_id'    => $source_id,
+                'target_id'    => (int) $candidate->ID,
+                'target_title' => $candidate->post_title,
+                'target_url'   => get_permalink( $candidate->ID ),
+                'keyword'      => $keyword['display'],
+                'match_text'   => $hit['match_text'],
+                'snippet'      => $hit['snippet'],
+                'hit_at'       => $hit['hit_at'],   // where in the snippet the linked words are (chars)
+                'hit_len'      => $hit['hit_len'],
+                'char_offset'  => $hit['offset'],
+            ];
+        }
+
+        return [
+            'matches'          => $matches,
+            'total_candidates' => $total,
+            'scanned'          => max( 0, $end - $offset ),
+            'offset'           => $offset,
+        ];
+    }
+
+    /**
+     * Content as TextMatch reads it: [html without captions/shortcodes/quotes/alt,
+     * plain text, plain text with existing links' words blanked out]. The two
+     * texts have the same length, so offsets line up — a mention is only
+     * offered where it is NOT already inside a link.
+     */
+    private static function prepare( string $content ): array {
+        // Strip content sections where links can't be placed or are
+        // misleading: figure captions, [caption] shortcodes, blockquotes,
+        // image alt attributes, and all remaining shortcodes (whose
+        // attributes like name="..." are a common source of false positives).
+        $content = preg_replace( '/\[caption[^\]]*\].*?\[\/caption\]/is', '', $content );
+        $content = strip_shortcodes( $content );
+        $content = preg_replace( '/<figcaption[^>]*>.*?<\/figcaption>/is', '', $content );
+        $content = preg_replace( '/<blockquote[^>]*>.*?<\/blockquote>/is', '', $content );
+        // Code, scripts, form controls and comments are not text to link (0.98.158, HtmlPlaces::no_go_ranges).
+        $content = preg_replace( '/<(script|style|code|pre|textarea|button|select|svg)\b[^>]*>.*?<\/\1>/is', '', $content );
+        $content = preg_replace( '/<!--.*?-->/s', '', $content );
+        $content = preg_replace( '/alt=["\'][^"\']*["\']/i', '', $content );
+        return [ $content, self::plain( $content ), self::plain( self::mask_anchors( $content ) ) ];
+    }
+
+    /**
+     * Reverse scan: other articles that mention $target_id's title (same
+     * keyword rules as scan()) without linking to it. Same row shape as
+     * scan(), with source_* filled — Apply goes through tms/apply as usual.
+     *
+     * A LIKE on the keyword narrows the sources first, so this stays fast
+     * on large sites; every hit is then checked exactly like a forward scan.
+     */
+    public function scan_for_target( int $target_id, int $limit = 30 ): array {
+        $target = get_post( $target_id );
+        if ( ! $target || Scope::excludes( $target ) ) return [];
+        $keyword = $this->build_keyword( $target->post_title, $target->post_type === Shop::TYPE );
+        if ( ! $keyword ) return [];
+
+        global $wpdb;
+        $like = '%' . $wpdb->esc_like( wp_strip_all_tags( html_entity_decode( (string) $keyword['display'], ENT_QUOTES, 'UTF-8' ) ) ) . '%';
+        // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+        $rows = $wpdb->get_results( $wpdb->prepare(
+            "SELECT p.ID, p.post_title FROM {$wpdb->posts} p
+             WHERE " . Scope::sql( 'p' ) . " AND " . Scope::type_sql( 'p' ) . " AND " . Lang::sql( Lang::of( $target_id ), 'p' ) . " AND p.ID != %d AND p.post_content LIKE %s
+             ORDER BY p.ID ASC LIMIT 300",
+            $target_id, $like
+        ) );
+        // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        if ( ! $rows ) return [];
+
+        $linking = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare(
+            "SELECT DISTINCT source_id FROM {$wpdb->prefix}clusterlane_links WHERE target_id = %d AND target_type = 'post' AND link_type = 'inline'", $target_id
+        ) ) );
+        $target_url = get_permalink( $target_id );
+        $out = [];
+        foreach ( $rows as $r ) {
+            $sid = (int) $r->ID;
+            if ( in_array( $sid, $linking, true ) ) continue;
+            $src = get_post( $sid );
+            if ( ! $src ) continue;
+            [ $content, $text, $free ] = self::prepare( (string) $src->post_content );
+            if ( in_array( 'post:' . $target_id, $this->extract_linked_ids( $content ), true ) ) continue;
+            if ( $this->title_already_linked( $target->post_title, $content ) ) continue;
+            $hit = $this->find_match( $text, $free, $keyword );
+            if ( ! $hit ) continue;
+            $out[] = [
+                'source_id'    => $sid,
+                'source_title' => $r->post_title,
+                'source_url'   => get_permalink( $sid ),
+                'target_id'    => $target_id,
+                'target_title' => $target->post_title,
+                'target_url'   => $target_url,
+                'keyword'      => $keyword['display'],
+                'match_text'   => $hit['match_text'],
+                'snippet'      => $hit['snippet'],
+                'hit_at'       => $hit['hit_at'],
+                'hit_len'      => $hit['hit_len'],
+                'char_offset'  => $hit['offset'],
+            ];
+            if ( count( $out ) >= $limit ) break;
+        }
+        return $out;
+    }
+
+    /**
+     * Apply a link to the first unlinked occurrence of $match_text in $source_id.
+     */
+    public function apply_link( int $source_id, string $match_text, string $target_url ): bool {
+        $post = get_post( $source_id );
+        if ( ! $post ) return false;
+
+        $match_text = trim( $match_text );
+        $anchor     = '<a href="' . esc_url( $target_url ) . '">' . esc_html( $match_text ) . '</a>';
+
+        // Elementor article: link the phrase inside its Text Editor widgets.
+        if ( class_exists( ElementorWriter::class ) && ElementorWriter::is_elementor_post( $source_id ) && \Clusterlane\Core\Edition::has( 'elementor' ) && apply_filters( 'clusterlane_elementor_write', true ) ) {
+            $data = ElementorWriter::replace_first( $source_id, fn( string $html ): string => $this->replace_outside_anchors( $html, $match_text, $anchor ) );
+            if ( ! is_array( $data ) ) return false;
+            return ElementorWriter::save( $source_id, $data ) === true;
+        }
+
+        $new_content = $this->replace_outside_anchors( $post->post_content, $match_text, $anchor );
+
+        if ( $new_content === $post->post_content ) return false;
+
+        // wp_update_post() expects slashed data (it runs wp_unslash() before saving) —
+        // without wp_slash() every backslash in the article would be stripped.
+        $result = wp_update_post( wp_slash( [ 'ID' => $source_id, 'post_content' => $new_content ] ), true );
+        return $result && ! is_wp_error( $result );
+    }
+
+    /**
+     * Get all published post IDs for the scanner JS batch loop.
+     *
+     * @return int[]
+     */
+    public function get_all_post_ids( int $cat_id = 0 ): array {
+        $args = Scope::query_args( [
+            'post_type'              => Scope::post_types(),
+            'post_status'            => 'publish',
+            'posts_per_page'         => -1,
+            'fields'                 => 'ids',
+            'orderby'                => 'ID',
+            'order'                  => 'ASC',
+            'no_found_rows'          => true,
+            'update_post_meta_cache' => false,
+            'update_post_term_cache' => false,
+        ] );
+        if ( $cat_id > 0 ) {
+            $args['cat'] = $cat_id;
+        }
+        return get_posts( $args );
+    }
+
+    /* ------------------------------------------------------------------
+       Private helpers
+    ------------------------------------------------------------------ */
+
+    /** Load all published posts (ID + title), optionally filtered by category. Cached per request. */
+    private int $match_cat_id = 0;
+
+    public function set_match_cat_id( int $id ): void {
+        $this->match_cat_id = $id;
+    }
+
+    /** @var int[]|null Restrict link targets to these posts (a cluster's members). */
+    private ?array $candidate_ids = null;
+
+    public function set_candidate_ids( ?array $ids ): void {
+        $this->candidate_ids = $ids === null ? null : array_map( 'intval', $ids );
+        $this->cand_sig      = $ids === null ? '*' : md5( implode( ',', $this->candidate_ids ) );
+    }
+
+    private function get_candidate_posts(): array {
+        if ( $this->candidate_ids !== null ) {
+            // Filter the (cached) full list — keeps the site-wide order and titles.
+            $keep = array_flip( $this->candidate_ids );
+            $all  = $this->get_candidate_posts_all();
+            return array_values( array_filter( $all, fn( $r ) => isset( $keep[ (int) $r->ID ] ) ) );
+        }
+        return $this->get_candidate_posts_all();
+    }
+
+    private function get_candidate_posts_all(): array {
+        static $cache = [];
+        $key = $this->match_cat_id;
+        if ( isset( $cache[ $key ] ) ) return $cache[ $key ];
+
+        global $wpdb;
+
+        if ( $key > 0 ) {
+            // Filter by category via term_relationships.
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $cache[ $key ] = $wpdb->get_results( $wpdb->prepare(
+                "SELECT p.ID, p.post_title, p.post_type
+                 FROM {$wpdb->posts} p
+                 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
+                 INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
+                 WHERE " . Scope::sql( 'p' ) . "
+                   AND " . Scope::type_sql( 'p' ) . "
+                   AND tt.taxonomy   = 'category'
+                   AND tt.term_id    = %d
+                 ORDER BY p.ID ASC",
+                $key
+            ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        } else {
+            // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare().
+            $cache[ $key ] = $wpdb->get_results( $wpdb->prepare(
+                'SELECT ID, post_title, post_type FROM %i
+                 WHERE ' . Scope::sql() . ' AND ' . Scope::type_sql() . '
+                 ORDER BY ID ASC',
+                $wpdb->posts
+            ) );
+            // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared
+        }
+        return $cache[ $key ];
+    }
+
+    /**
+     * Build a keyword descriptor from a post title.
+     *
+     * @return array|null { type, words, pattern, display }
+     */
+    private function build_keyword( string $title, bool $whole_name = false ): ?array {
+        // Check if the whole title is suppressed by a regex stop pattern.
+        if ( $this->title_matches_stop_pattern( $title ) ) return null;
+
+        // Strip subtitle after separator.
+        foreach ( [ ': ', ' – ', ' — ', ' - ' ] as $sep ) {
+            if ( strpos( $title, $sep ) !== false ) {
+                $title = trim( substr( $title, 0, strpos( $title, $sep ) ) );
+                break;
+            }
+        }
+
+        // Suppress if the (stripped) title matches a plain multi-word stop phrase.
+        if ( $this->is_stop_phrase( $title ) ) return null;
+
+        $words = preg_split( '/\s+/', $title, -1, PREG_SPLIT_NO_EMPTY );
+
+        if ( count( $words ) === 1 ) {
+            $w = strtolower( $words[0] );
+            if ( strlen( $w ) < self::MIN_KEYWORD_LEN || $this->is_stop_word( $w ) ) {
+                return null;
+            }
+            return [
+                'type'    => 'exact',
+                'words'   => $words,
+                'pattern' => '/(?<![a-zA-ZÀ-öø-ÿ])' . preg_quote( $title, '/' ) . '(?![a-zA-ZÀ-öø-ÿ\-]|(?:\s+\d))/iu',
+                'display' => $title,
+            ];
+        }
+
+        // Products (0.98.172): the whole name, exactly — "Old Town Mug No. 1" is a
+        // name, not a topic to look for word by word.
+        if ( count( $words ) <= 4 || ( $whole_name && count( $words ) <= 8 ) ) {
+            return [
+                'type'    => 'exact',
+                'words'   => $words,
+                'pattern' => '/(?<![a-zA-ZÀ-öø-ÿ])' . preg_quote( $title, '/' ) . '(?![a-zA-ZÀ-öø-ÿ\-]|(?:\s+\d))/iu',
+                'display' => $title,
+            ];
+        }
+
+        // Long title — proximity match on significant words.
+        $sig = array_values( array_filter( $words, fn( $w ) =>
+            strlen( $w ) >= self::MIN_KEYWORD_LEN && ! $this->is_stop_word( $w )
+        ) );
+
+        if ( count( $sig ) < 3 ) return null;
+
+        return [
+            'type'    => 'proximity',
+            'words'   => $sig,
+            'display' => implode( ' + ', $sig ),
+        ];
+    }
+
+    /**
+     * Text of HTML without tags (script/style removed), NOT trimmed — so a
+     * masked copy (mask_anchors) keeps exactly the same byte offsets.
+     */
+    private static function plain( string $html ): string {
+        $html = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $html );
+        return strip_tags( $html ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags() trims, which would shift the offsets
+    }
+
+    /** Replace the visible words inside every <a>…</a> with spaces of equal byte length. */
+    private static function mask_anchors( string $html ): string {
+        return preg_replace_callback( '/(<a\b[^>]*>)(.*?)(<\/a>)/is', function ( $m ) {
+            $inner = preg_replace_callback( '/[^<>]+(?=<|$)/', fn( $t ) => str_repeat( ' ', strlen( $t[0] ) ), $m[2] );
+            return $m[1] . $inner . $m[3];
+        }, $html );
+    }
+
+    /**
+     * First mention of keyword that is not already inside a link.
+     * Searches $free (links blanked) and reads the words from $text.
+     */
+    private function find_match( string $text, string $free, array $keyword ): ?array {
+        if ( $keyword['type'] === 'exact' ) {
+            if ( ! preg_match( $keyword['pattern'], $free, $m, PREG_OFFSET_CAPTURE ) ) return null;
+
+            $offset     = $m[0][1];
+            $match_text = substr( $text, $offset, strlen( $m[0][0] ) );
+
+            return [
+                'match_text' => $match_text,
+                'snippet'    => $this->snippet( $text, $offset, strlen( $match_text ) ),
+                'offset'     => $offset,
+            ] + $this->snippet_hit( $text, $offset, strlen( $match_text ) );
+        }
+
+        // Proximity mode (words searched outside existing links too).
+        $offsets = [];
+        foreach ( $keyword['words'] as $w ) {
+            $pat = '/(?<![a-zA-ZÀ-öø-ÿ])' . preg_quote( $w, '/' ) . '(?![a-zA-ZÀ-öø-ÿ\-])/iu';
+            if ( ! preg_match( $pat, $free, $m, PREG_OFFSET_CAPTURE ) ) return null;
+            $offsets[] = $m[0][1];
+        }
+
+        $min = min( $offsets );
+        $max = max( $offsets ) + strlen( end( $keyword['words'] ) );
+
+        if ( ( $max - $min ) > self::PROXIMITY_WINDOW ) return null;
+
+        $match_text = substr( $text, $min, $max - $min );
+
+        return [
+            'match_text' => $match_text,
+            'snippet'    => $this->snippet( $text, $min, $max - $min ),
+            'offset'     => $min,
+        ] + $this->snippet_hit( $text, $min, $max - $min );
+    }
+
+    /**
+     * Returns true if $pos (byte offset in $html) sits inside a shortcode tag,
+     * i.e. between [ and ] where the bracket pair is not a closing shortcode.
+     * This prevents linking text that appears as a shortcode attribute value,
+     * e.g. [ml_featured_hotel name="Hotel Sven Vintappare"].
+     */
+    private function is_inside_shortcode( string $html, int $pos ): bool {
+        $before     = substr( $html, 0, $pos );
+        $last_open  = strrpos( $before, '[' );
+        $last_close = strrpos( $before, ']' );
+
+        if ( $last_open === false ) return false;
+        if ( $last_close !== false && $last_close > $last_open ) return false;
+
+        // We're between [ and the next ]. Confirm the ] actually closes this bracket.
+        $after      = substr( $html, $pos );
+        $next_close = strpos( $after, ']' );
+        return $next_close !== false;
+    }
+
+
+    /**
+     * Where the matched words sit inside snippet() — in characters — so the UI
+     * can mark exactly the occurrence that Apply will link (not every repeat).
+     *
+     * @return array{hit_at:int, hit_len:int}
+     */
+    private function snippet_hit( string $text, int $offset, int $len ): array {
+        $start = max( 0, $offset - self::SNIPPET_CONTEXT );
+        $pre   = substr( $text, $start, $offset - $start );
+        if ( $start > 0 ) $pre = '…' . ltrim( $pre );
+        $dec = fn( string $x ): string => self::tidy( self::decode( $x ) );
+        // Code points (not UTF-16 units) — the JS slices with Array.from().
+        return [
+            'hit_at'  => mb_strlen( $dec( $pre ), 'UTF-8' ),
+            'hit_len' => mb_strlen( $dec( substr( $text, $offset, $len ) ), 'UTF-8' ),
+        ];
+    }
+
+    /**
+     * Undo a Text Match link: turn the first <a href="$target_url">$text</a>
+     * that Clusterlane inserted back into plain text.
+     */
+    public function remove_link( int $source_id, string $match_text, string $target_url ): bool {
+        $post = get_post( $source_id );
+        if ( ! $post ) return false;
+        // Match the exact anchor Clusterlane writes; the words are compared
+        // case-insensitively and put back exactly as they stand in the
+        // article (apply_link() keeps the article's own spelling).
+        $rx   = '/<a href="' . preg_quote( esc_url( $target_url ), '/' ) . '">(' . preg_quote( esc_html( trim( $match_text ) ), '/' ) . ')<\/a>/iu';
+        $undo = fn( string $html ): string => (string) preg_replace( $rx, '$1', $html, 1 );
+
+        if ( class_exists( ElementorWriter::class ) && ElementorWriter::is_elementor_post( $source_id ) && \Clusterlane\Core\Edition::has( 'elementor' ) && apply_filters( 'clusterlane_elementor_write', true ) ) {
+            $data = ElementorWriter::replace_first( $source_id, $undo );
+            if ( ! is_array( $data ) ) return false;
+            return ElementorWriter::save( $source_id, $data ) === true;
+        }
+
+        $new = $undo( $post->post_content );
+        if ( $new === $post->post_content ) return false;
+        $result = wp_update_post( wp_slash( [ 'ID' => $source_id, 'post_content' => $new ] ), true );
+        return $result && ! is_wp_error( $result );
+    }
+
+    /**
+     * Decode HTML entities for display — repeatedly, because imported or
+     * pasted content is sometimes double-encoded ("&amp;amp;" → "&amp;" → "&").
+     */
+    private static function decode( string $x ): string {
+        for ( $i = 0; $i < 3; $i++ ) {
+            $d = html_entity_decode( $x, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+            if ( $d === $x ) break;
+            $x = $d;
+        }
+        return $x;
+    }
+
+    /** Build a readable context snippet. */
+    private function snippet( string $text, int $offset, int $len ): string {
+        $start   = max( 0, $offset - self::SNIPPET_CONTEXT );
+        $end     = min( strlen( $text ), $offset + $len + self::SNIPPET_CONTEXT );
+        $snippet = substr( $text, $start, $end - $start );
+
+        if ( $start > 0 ) $snippet = '…' . ltrim( $snippet );
+        if ( $end < strlen( $text ) ) $snippet = rtrim( $snippet ) . '…';
+
+        // Readable text: "&amp;" → "&" (snippet_hit() counts on the same decoded text).
+        return self::tidy( self::decode( $snippet ) );
+    }
+
+    /**
+     * Block boundaries in a snippet (0.98.185): a heading and the paragraph after it
+     * were joined into one sentence ("Getting there Entry is included…"). A line
+     * break after a heading-like line becomes " · ", after a full stop a space.
+     * snippet_hit() applies the same, so the marked words stay in place.
+     */
+    private static function tidy( string $x ): string {
+        $x = (string) preg_replace_callback( '/(\S?)[ \t]*\R\s*/u', function ( $m ) {
+            if ( $m[1] === '' ) return ' ';
+            return $m[1] . ( preg_match( '/[.!?:;…,]/u', $m[1] ) ? ' ' : ' · ' );
+        }, $x );
+        return $x;
+    }
+
+    /**
+     * Replace first occurrence of $needle outside <a> tags.
+     * Uses mb_-safe string ops throughout.
+     */
+    private function replace_outside_anchors( string $html, string $needle, string $replacement ): string {
+        // Every occurrence is tried in order (not just the first two), with the
+        // same rules as the scan, so Apply links the mention the scan showed:
+        //   - not inside an existing <a>…</a>
+        //   - not inside an HTML tag (e.g. data-* attributes) or a shortcode
+        //   - not inside <blockquote>, <figcaption> or [caption] (skipped by the scan too)
+        //   - whole words only
+        $needle = trim( $needle );
+        if ( $needle === '' ) return $html;
+        $pattern = '/(?<![\p{L}\p{N}])' . preg_quote( $needle, '/' ) . '(?![\p{L}\p{N}])/iu';
+
+        $parts = preg_split( '/(<a[\s>].*?<\/a>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
+
+        foreach ( $parts as $i => &$part ) {
+            if ( $i % 2 === 1 ) continue; // an existing link
+            if ( ! preg_match_all( $pattern, $part, $hits, PREG_OFFSET_CAPTURE ) ) continue;
+
+            // Regions the scan ignores (byte ranges within this part).
+            $skip = [];
+            foreach ( [ '/<blockquote\b[^>]*>.*?<\/blockquote>/is', '/<figcaption\b[^>]*>.*?<\/figcaption>/is', '/\[caption[^\]]*\].*?\[\/caption\]/is' ] as $rx ) {
+                if ( preg_match_all( $rx, $part, $rm, PREG_OFFSET_CAPTURE ) ) {
+                    foreach ( $rm[0] as $r ) $skip[] = [ $r[1], $r[1] + strlen( $r[0] ) ];
+                }
+            }
+            // Scripts, styles, code, form controls, buttons, comments (0.98.158).
+            $skip = array_merge( $skip, HtmlPlaces::no_go_ranges( $part ) );
+
+            foreach ( $hits[0] as $h ) {
+                $pos    = $h[1];                       // byte offset
+                $before = substr( $part, 0, $pos );
+                $lo     = strrpos( $before, '<' );
+                $lc     = strrpos( $before, '>' );
+                if ( $lo !== false && ( $lc === false || $lo > $lc ) ) continue;                          // inside a tag
+                if ( $this->is_inside_shortcode( $part, $pos ) ) continue;                               // inside a shortcode (byte offset, like substr() in the check)
+                foreach ( $skip as $r ) { if ( $pos >= $r[0] && $pos < $r[1] ) continue 2; }            // quote / caption
+
+                // Link the words exactly as written in the article (the match is
+                // case-insensitive, so the needle's casing may differ).
+                $link = str_replace( '>' . esc_html( $needle ) . '</a>', '>' . $h[0] . '</a>', $replacement );
+                $part = substr( $part, 0, $pos ) . $link . substr( $part, $pos + strlen( $h[0] ) );
+                unset( $part );
+                return implode( '', $parts );
+            }
+        }
+        unset( $part );
+        return $html;
+    }
+
+    /**
+     * Returns true if $title already appears as the text node of an <a> tag
+     * in $html — regardless of which URL the href points to.
+     */
+    /**
+     * Check whether $title is already used as anchor text for a link in $html.
+     *
+     * Two cases are handled:
+     *  1. Exact match  — the anchor text equals $title exactly.
+     *  2. Partial match — one string is a leading prefix of the other (≥10 chars).
+     *     Catches the common case where a category archive is linked as
+     *     "Science Museums in Stockholm" while the candidate post is titled
+     *     "Science Museums in Stockholm: Innovation & Discovery".
+     */
+    private function title_already_linked( string $title, string $html, ?array $anchors = null ): bool {
+        foreach ( $anchors ?? self::anchor_texts( $html ) as $anchor ) {
+
+            // Exact match (case-insensitive).
+            if ( strcasecmp( $anchor, $title ) === 0 ) return true;
+
+            // Partial/prefix match — one is a leading substring of the other.
+            $min = min( strlen( $anchor ), strlen( $title ) );
+            if ( $min >= 10 && strncasecmp( $anchor, $title, $min ) === 0 ) return true;
+        }
+        return false;
+    }
+
+    /** The visible texts of every link in $html (trimmed, non-empty). */
+    private static function anchor_texts( string $html ): array {
+        preg_match_all( '/<a\b[^>]*>(.*?)<\/a>/is', $html, $m );
+        return array_values( array_filter( array_map( fn( $a ) => trim( wp_strip_all_tags( $a ) ), $m[1] ), fn( $a ) => $a !== '' ) );
+    }
+
+    /** Lower case for the pre-test (multibyte when available). */
+    private static function lc( string $s ): string {
+        return function_exists( 'mb_strtolower' ) ? mb_strtolower( $s, 'UTF-8' ) : strtolower( $s );
+    }
+
+    /** Candidate indexes per request: list signature → [list, kw, tok → positions, any]. */
+    private array $cidx = [];
+    private string $cand_sig = '*';
+
+    /**
+     * The candidate list (in scope, optionally one language) and, for each title with a
+     * keyword, its position under its first word ('tok') — or under 'any' when the name
+     * starts with no letter (always tried). Titles without a keyword are left out: they
+     * never match. (0.98.173)
+     * @return array{list: object[], kw: array<int,array>, tok: array<string,int[]>, any: int[]}
+     */
+    private function candidate_index( string $lang ): array {
+        $key = $this->match_cat_id . '|' . $this->cand_sig . '|' . $lang;
+        if ( isset( $this->cidx[ $key ] ) ) return $this->cidx[ $key ];
+        $list = $this->get_candidate_posts();
+        if ( $lang !== '' ) {
+            $keep = array_flip( Lang::filter_ids( array_map( fn( $r ) => (int) $r->ID, $list ), $lang ) );
+            $list = array_values( array_filter( $list, fn( $r ) => isset( $keep[ (int) $r->ID ] ) ) );
+        }
+        $out = [ 'list' => $list, 'kw' => [], 'tok' => [], 'any' => [] ];
+        $df  = []; // in how many names each word occurs — each name is filed under its rarest word
+        foreach ( $list as $i => $c ) {
+            $k = $this->keyword( $c->post_title, ( $c->post_type ?? '' ) === Shop::TYPE );
+            if ( ! $k ) continue;
+            $out['kw'][ $i ] = $k;
+            foreach ( $k['toks'] ?? [] as $t ) $df[ $t ] = ( $df[ $t ] ?? 0 ) + 1;
+        }
+        foreach ( $out['kw'] as $i => $k ) {
+            if ( empty( $k['toks'] ) ) { $out['any'][] = $i; continue; }
+            $best = $k['toks'][0];
+            foreach ( $k['toks'] as $t ) if ( $df[ $t ] < $df[ $best ] ) $best = $t;
+            $out['tok'][ $best ][] = $i;
+        }
+        return $this->cidx[ $key ] = $out;
+    }
+
+    /** build_keyword(), once per title per request. */
+    private function keyword( string $title, bool $product = false ): ?array {
+        $ck = ( $product ? "p\x1f" : '' ) . $title;
+        if ( ! array_key_exists( $ck, $this->kw_cache ) ) {
+            $k = $this->build_keyword( $title, $product );
+            // The literal words the pattern looks for, in lower case: if one is missing
+            // from the text, the (case-insensitive) pattern cannot match there either.
+            if ( $k ) {
+                $k['needles'] = $k['type'] === 'exact' ? [ self::lc( $k['display'] ) ] : array_map( [ self::class, 'lc' ], $k['words'] );
+                // Every run of letters in the name is a whole run of letters in the text
+                // wherever the pattern matches: inside the name it is bounded by the name's
+                // own non-letters, and at its ends the pattern demands a non-letter. So each
+                // is an exact word_set() key, and all must be present for a match.
+                $toks = [];
+                foreach ( $k['needles'] as $n ) {
+                    if ( preg_match_all( '/' . self::LETTERS . '+/u', $n, $fm ) ) foreach ( $fm[0] as $t ) $toks[ $t ] = true;
+                }
+                if ( $toks ) $k['toks'] = array_keys( $toks );
+            }
+            $this->kw_cache[ $ck ] = $k;
+        }
+        return $this->kw_cache[ $ck ];
+    }
+
+    /** The letters the patterns treat as word characters (their lookbehind/lookahead class), lower case. */
+    private const LETTERS = '[a-zſß×à-öø-ÿ]'; // = [a-zA-ZÀ-öø-ÿ] under /iu, after mb_strtolower (ß, × and the long s fold into it)
+
+    /** Every maximal run of LETTERS in a lower-cased text, as keys. */
+    private static function word_set( string $text_lc ): array {
+        return array_flip( preg_split( '/(?:(?!' . self::LETTERS . ').)+/su', $text_lc, -1, PREG_SPLIT_NO_EMPTY ) ?: [] );
+    }
+
+    /** False only when the keyword cannot occur in the (lower-cased) text. */
+    private static function may_occur( string $text_lc, array $keyword ): bool {
+        foreach ( $keyword['needles'] ?? [] as $n ) {
+            if ( $n !== '' && strpos( $text_lc, $n ) === false ) return false;
+        }
+        return true;
+    }
+
+    /**
+     * Extract already-linked target identifiers from HTML content.
+     *
+     * Returns "type:id" strings (e.g. "post:42", "term:7") so that post IDs
+     * and term IDs — independent numeric sequences in WordPress — can never
+     * collide when TMS checks whether a candidate has already been linked.
+     *
+     * @return string[]
+     */
+    private function extract_linked_ids( string $html ): array {
+        $keys = [];
+        preg_match_all( '/href=["\'"]([^"\'"]+)["\']/i', $html, $m );
+
+        foreach ( $m[1] as $url ) {
+            // The same links recur across articles — resolve each URL once per request (0.98.173).
+            if ( array_key_exists( $url, $this->url_keys ) ) { if ( $this->url_keys[ $url ] !== '' ) $keys[] = $this->url_keys[ $url ]; continue; }
+            $before = count( $keys );
+            $this->resolve_linked_url( $url, $keys );
+            $this->url_keys[ $url ] = count( $keys ) > $before ? end( $keys ) : '';
+        }
+
+        return array_unique( $keys );
+    }
+
+    /** URL → "type:id" per request, for extract_linked_ids(). */
+    private array $url_keys = [];
+
+    /** Adds the "type:id" key $url points at (if any) to $keys. */
+    private function resolve_linked_url( string $url, array &$keys ): void {
+        foreach ( [ $url ] as $url ) {
+            if ( ! \Clusterlane\Core\Multisite::is_internal_url( $url ) ) continue;
+            $full = \Clusterlane\Core\Multisite::absolutize( \Clusterlane\Core\Multisite::normalize_url( $url ) );
+
+            // Try post first (most common case); custom post types on plain permalinks too.
+            $post_id = url_to_postid( $full ) ?: ( strpos( $full, '?' ) !== false ? \Clusterlane\Core\LinkGraph::query_var_id( $full ) : 0 );
+            if ( $post_id ) {
+                $keys[] = 'post:' . $post_id;
+                continue;
+            }
+            // By slug, current or old (WordPress redirects an old one) — as the link graph does (0.98.167).
+            $old = \Clusterlane\Core\LinkGraph::slug_id( (string) preg_replace( '/\.html?$/i', '', basename( rtrim( (string) wp_parse_url( $full, PHP_URL_PATH ), '/' ) ) ) );
+            if ( $old ) {
+                $keys[] = 'post:' . $old;
+                continue;
+            }
+
+            // Try term archive — check category and post_tag by slug.
+            $slug = basename( rtrim( wp_parse_url( $full, PHP_URL_PATH ), '/' ) );
+            foreach ( [ 'category', 'post_tag' ] as $tax ) {
+                $term = get_term_by( 'slug', $slug, $tax );
+                if ( $term && ! is_wp_error( $term ) ) {
+                    $keys[] = 'term:' . $term->term_id;
+                    break;
+                }
+            }
+        }
+
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/core/class-thumbs.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/core/class-thumbs.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/core/class-thumbs.php	(revision 3751027)
@@ -0,0 +1,66 @@
+<?php
+namespace Clusterlane\Core;
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Thumbs — small featured-image URLs for article lists (search, Articles to
+ * review, cluster members, Text Match / link rows, graph tooltips).
+ *
+ * One batch per list: post meta and the attachment posts are primed first,
+ * so a list of 50 articles costs a couple of queries, not 100.
+ * Setting show_thumbnails (Settings → General) turns them off everywhere:
+ * urls() then returns '' for every ID and the lists show no image at all.
+ */
+final class Thumbs {
+
+    public static function enabled(): bool {
+        $s = Settings::get_instance();
+        return $s ? (bool) $s->get( 'show_thumbnails', true ) : true;
+    }
+
+    /**
+     * @param  int[] $post_ids
+     * @return array<int,string> post ID → thumbnail URL ('' when none / disabled)
+     */
+    public static function urls( array $post_ids ): array {
+        $ids = array_values( array_unique( array_filter( array_map( 'intval', $post_ids ) ) ) );
+        $out = array_fill_keys( $ids, '' );
+        if ( ! $ids || ! self::enabled() ) return $out;
+
+        update_meta_cache( 'post', $ids );
+        $att = [];
+        foreach ( $ids as $id ) {
+            $a = (int) get_post_meta( $id, '_thumbnail_id', true );
+            if ( $a ) $att[ $id ] = $a;
+        }
+        if ( ! $att ) return $out;
+        if ( function_exists( '_prime_post_caches' ) ) _prime_post_caches( array_values( $att ), false, true );
+        foreach ( $att as $id => $a ) {
+            $url = wp_get_attachment_image_url( $a, 'thumbnail' );
+            $out[ $id ] = $url ? (string) $url : '';
+        }
+        return $out;
+    }
+
+    /** One URL (for single-post responses). */
+    public static function url( int $post_id ): string {
+        return self::urls( [ $post_id ] )[ $post_id ] ?? '';
+    }
+
+    /**
+     * Add a thumbnail field to each row of a list, reading the post ID from $id_key.
+     * Works for arrays of arrays and arrays of objects.
+     */
+    public static function add( array $rows, string $id_key = 'id', string $field = 'thumb' ): array {
+        $ids = [];
+        foreach ( $rows as $r ) $ids[] = (int) ( is_array( $r ) ? ( $r[ $id_key ] ?? 0 ) : ( $r->$id_key ?? 0 ) );
+        $urls = self::urls( $ids );
+        foreach ( $rows as $k => $r ) {
+            $id = (int) ( is_array( $r ) ? ( $r[ $id_key ] ?? 0 ) : ( $r->$id_key ?? 0 ) );
+            if ( is_array( $r ) ) $rows[ $k ][ $field ] = $urls[ $id ] ?? '';
+            else $rows[ $k ]->$field = $urls[ $id ] ?? '';
+        }
+        return $rows;
+    }
+}
Index: /clusterlane/tags/1.3.2/intelligence/uninstall-data.php
===================================================================
--- /clusterlane/tags/1.3.2/intelligence/uninstall-data.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/intelligence/uninstall-data.php	(revision 3751027)
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Clusterlane — remove everything the plugin stores (1.2.0: shared by both builds).
+ *
+ * Free (WordPress.org): uninstall.php calls clusterlane_uninstall_data().
+ * Pro (Freemius): there is no uninstall.php — Freemius runs its own uninstall
+ * hook and then fires after_uninstall, which calls clusterlane_uninstall_data()
+ * (see pro__premium_only/freemius-init.php).
+ *
+ * Removes tables, options, transients, post meta, scheduled events and the
+ * capabilities added to roles; on multisite for every site, plus the
+ * network-wide AI settings. Deactivating does NOT run this.
+ */
+
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+if ( ! function_exists( 'clusterlane_uninstall_data' ) ) {
+
+    /**
+     * @param string $self Basename of the copy being deleted (e.g. clusterlane/clusterlane.php).
+     */
+    function clusterlane_uninstall_data( string $self ): void {
+        /*
+         * Never wipe data that another active copy of Clusterlane is using: the free
+         * version and Pro (its own folder, clusterlane-premium) share tables and
+         * settings.
+         */
+        $active = array_merge(
+            (array) get_option( 'active_plugins', [] ),
+            is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) : []
+        );
+        foreach ( $active as $plugin ) {
+            if ( $plugin !== $self && preg_match( '#(^|/)clusterlane\.php$#', (string) $plugin ) ) {
+                return; // another Clusterlane is active — keep everything
+            }
+        }
+
+        if ( is_multisite() ) {
+            delete_site_option( 'clusterlane_network_ai' ); // network-wide AI key and policy
+            foreach ( get_sites( [ 'fields' => 'ids', 'number' => 0 ] ) as $site_id ) {
+                switch_to_blog( (int) $site_id );
+                clusterlane_uninstall_data_site();
+                restore_current_blog();
+            }
+        } else {
+            clusterlane_uninstall_data_site();
+        }
+    }
+
+    /**
+     * Remove Clusterlane's data from the current site (switch_to_blog() first on
+     * multisite). Table names are listed explicitly, so a name collision can
+     * never drop unrelated tables.
+     */
+    function clusterlane_uninstall_data_site(): void {
+        global $wpdb;
+
+        // 1. Tables.
+        foreach ( [ 'clusterlane_links', 'clusterlane_clusters', 'clusterlane_post_metrics', 'clusterlane_ai_cache', 'clusterlane_custom_members', 'clusterlane_tms_skipped', 'clusterlane_cluster_post_class', 'clusterlane_snapshots', 'clusterlane_activity' ] as $bare ) {
+            $table = $wpdb->prefix . $bare;
+            $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- removing the plugin's own tables on uninstall
+        }
+
+        // 2. Options.
+        foreach ( [ 'clusterlane_settings', 'clusterlane_db_version', 'clusterlane_caps_version', 'clusterlane_ai_usage', 'clusterlane_chosen_pillars', 'clusterlane_cluster_sections', 'clusterlane_cluster_groups', 'clusterlane_data_version', 'clusterlane_last_snapshot', 'clusterlane_render_log', 'clusterlane_page_queue', 'clusterlane_page_lock', 'clusterlane_hub_relations', 'clusterlane_migrated', 'clusterlane_migrating' ] as $option ) {
+            delete_option( $option );
+        }
+
+        // 3. Transients: undo data for SEO fields and applied links (30 minutes each).
+        $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+            "DELETE FROM {$wpdb->options}
+             WHERE option_name LIKE '\\_transient\\_clusterlane\\_%' OR option_name LIKE '\\_transient\\_timeout\\_clusterlane\\_%'"
+        );
+
+        // 4. Post meta: the draft marker from Gap Analysis and the Elementor
+        //    backup written before Clusterlane changed an Elementor page.
+        foreach ( [ '_clusterlane_gap_cluster', '_clusterlane_elementor_backup', '_clusterlane_seo_check', '_clusterlane_seo_check_min', '_clusterlane_seo_original', '_clusterlane_page_stats' ] as $meta_key ) {
+            delete_post_meta_by_key( $meta_key );
+        }
+
+        // 5. The daily snapshot event.
+        wp_clear_scheduled_hook( 'clusterlane_daily_snapshot' );
+        wp_clear_scheduled_hook( 'clusterlane_read_pages' );
+
+        // 6. Capabilities added to roles (roles are stored per site).
+        foreach ( wp_roles()->role_objects as $role ) {
+            $role->remove_cap( 'manage_clusterlane' );
+            $role->remove_cap( 'use_clusterlane' );
+            $role->remove_cap( 'use_clusterlane_ai' );
+            $role->remove_cap( 'clusterlane_add_links' );
+            $role->remove_cap( 'clusterlane_write_seo' );
+        }
+    }
+}
Index: /clusterlane/tags/1.3.2/readme.txt
===================================================================
--- /clusterlane/tags/1.3.2/readme.txt	(revision 3751027)
+++ /clusterlane/tags/1.3.2/readme.txt	(revision 3751027)
@@ -0,0 +1,127 @@
+=== Clusterlane ===
+Contributors: cenovia
+Tags: internal links, content clusters, seo, orphan pages, pillar pages
+Requires at least: 6.2
+Tested up to: 7.1
+Requires PHP: 8.0
+Stable tag: 1.3.2
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+See how your content hangs together — clusters, pillar pages and internal links — and what to fix first. You approve every change.
+
+== Description ==
+
+Clusterlane turns your categories, tags and other groupings — even pages your custom post types point to — into topic clusters, scores each one, and tells you on every screen what to do next: which articles nothing links to, which mentions can become links in one click, and which article should be the main one.
+
+**Free, with no limit on articles:**
+
+* **Site map** — every cluster at a glance, sized by articles and coloured by health.
+* **Cluster graphs** — the pillar, supporting articles and orphans of each cluster, and every link between them.
+* **Focus next** — the clusters where a few links make the biggest difference.
+* **Improve this cluster** and **Improve this article** — what to fix, most important first, with the fixes one click away.
+* **Text Match** — finds places where an article already names another article without linking to it. Apply links one by one, per article, or the whole list in one run; undo any link, or the whole run.
+* **Cluster quick view** — health and its trend, links, orphans, pillar and the articles to start with, for any cluster in the list.
+* **Choose each cluster's pillar** — Clusterlane picks the best-linked article, or you choose it yourself.
+* **SEO title and description preview** — from your SEO plugin (Yoast, Rank Math, SEOPress, All in One SEO, The SEO Framework) or your own fields and ACF fields, with length checks and a filter for missing or weak descriptions.
+* **SEO Titles** — every article's SEO title and meta description on one page, weakest first: filter by cluster, missing or weak descriptions, long titles and duplicates, edit by hand and apply to your own fields, with undo. The first time Clusterlane changes a title or description it keeps the original, so Restore original brings it back.
+* **Roles and capabilities** — decide who can use Clusterlane and who can change its settings.
+* **Theme links** — reads your published pages too, so links your theme adds through lists, cards and related sections count. Pages are read in the background, through the WordPress HTTP API.
+* **Pages as clusters** — for sites built from custom post types that point to each other (areas and their region, hotels and their destination): Clusterlane finds the custom fields by itself, and each page becomes a cluster with the pages pointing to it.
+* **Clusters from any taxonomy** — regions, types, genres: not only categories and tags.
+
+**Clusterlane never changes your content by itself.** Links, SEO fields and pillars only change when you click Apply or choose them, and links can be undone.
+
+Clusterlane Pro and Agency add the **Report** — a printable summary of your site with its history: cluster health and orphans over time, the work done and what to fix next (the history is collected in every edition, so it is there when you upgrade) — a **link to the pillar** at the end of every article in a cluster, which follows the pillar you choose — and AI features: a site analysis whose recommendations open the right tool, a **pillar and split check** for every cluster — which are too broad and should be split, and which have no natural main article, content gap analysis and briefs, AI-written link sentences and SEO title and description suggestions (one article at a time, or many at once in SEO Titles, choosing field by field what to keep) and an **AI review of every SEO title and description** against its article, ten articles per call — on your own OpenAI or Anthropic API key. See https://clusterlane.app/pricing/.
+
+== Installation ==
+
+1. Upload the plugin under Plugins → Add New → Upload Plugin, or install it from the plugin directory, and activate it.
+2. Open **Clusterlane → Cluster Admin** and choose which categories and tags should become clusters.
+3. Click **Scan** in the bar at the top of any Clusterlane page — it reads every article, builds the clusters and works out their health.
+4. Start on the Dashboard: *Focus next* shows the three clusters where a few links make the biggest difference.
+
+Full documentation: https://clusterlane.app/docs/
+
+== Frequently Asked Questions ==
+
+= Is there a limit on articles? =
+
+No.
+
+= Does Clusterlane change my content automatically? =
+
+No. Every change needs your Apply, and links can be undone.
+
+= Which editors are supported? =
+
+The block editor and the classic editor. Pages built with other page builders are detected, and Clusterlane explains that it won't write there.
+
+= Which post types are scanned? =
+
+Posts by default. Choose more — pages or your own post types — under Settings → Content, or in the first-run setup.
+
+= My theme links pages through lists or cards. Does Clusterlane see those links? =
+
+Yes, when you tick the post type under Settings → Content → Read the published pages. Clusterlane reads the main content of each published page (not menus, breadcrumbs, sidebars or the footer). Post types with almost no links in their text are marked Recommended.
+
+= My site uses custom post types instead of categories. Can they be clusters? =
+
+Yes. Pages as clusters in Cluster Admin finds custom fields that point to other pages — ACF post object and relationship fields, or any field holding a post ID — and makes each page a cluster with the pages pointing to it.
+
+= Does it work on multisite? =
+
+Yes. Each site keeps its own clusters and settings.
+
+= Does Clusterlane keep a history? =
+
+Yes. It saves the site's figures once a day (cluster health, orphans, links, roles, meta descriptions) and a log of the changes made through Clusterlane, so progress can be shown over time. Both stay in your database. **Delete All Data** under Settings keeps the history; deleting the plugin removes it.
+
+= What personal data does Clusterlane store? =
+
+Only the WordPress user ID of the editor who approved each change in the activity log. It is removed when that user is deleted. A suggested text is added under Settings → Privacy.
+
+= Where can I read more about clusters and internal linking? =
+
+The guides at https://clusterlane.app/guides/ explain topic clusters, pillar pages, orphan pages and internal linking in plain words.
+
+== Screenshots ==
+
+1. Cluster graph: the pillar, supporting articles and every link between them. Click an article to see its links in and out.
+2. Cluster Inspector: Focus next and every cluster with its health, articles, orphans and pillars.
+3. Article map: how one article links to the rest of the site, cluster by cluster.
+4. Article Inspector: every article with its score, role and links, weakest first.
+5. Text Match: places where an article already names another article without linking to it — link them with one click.
+6. Cluster Admin: choose which categories and tags become clusters.
+
+== Changelog ==
+
+= 1.3.2 =
+* Published pages are now fetched through the WordPress HTTP API (wp_remote_get), one after another, so the site's proxy, transport and HTTP filters always apply. Earlier versions fetched a few pages side by side with the Requests library directly.
+
+= 1.3.1 =
+* Sites that ran the plugin under its earlier name: Clusterlane no longer switches the old plugin off by itself. It shows a notice with a Deactivate button and waits; the data moves over once you have deactivated the old plugin.
+* Every database query goes through $wpdb->prepare(), with placeholders for all values and lists of IDs. Requires WordPress 6.2 or later.
+* All output is escaped where it is printed.
+
+= 1.3.0 =
+* New name: Clusterlane. Sites that ran the plugin under its earlier name keep everything — clusters, history, settings (including the AI key) and access rules move over on the first load.
+* Review fixes for WordPress.org: page scripts are added through the WordPress script API instead of inline tags, the settings form is sanitized field by field before anything else reads it, and the graph library is only loaded from the plugin itself.
+
+= 1.2.1 =
+* Phones: Missing content and Pillar & split check put the text above the buttons instead of squeezing it into a narrow column, and the AI Link Suggestion window fills the screen with the settings first and the live preview below.
+
+= 1.2.0 =
+* Free and Pro never run at the same time: activating Clusterlane Pro switches the free version off, and the free version stays off while Pro is active. Clusters, history and settings are shared, so nothing is lost.
+
+= 1.1.2 =
+* Code review against the WordPress.org Plugin Check: toolbar styles are added to the toolbar's own stylesheet, request values are unslashed and sanitized, and every database query is prepared or documented.
+
+= 1.1.1 =
+* Help → Admin & Support: new Site structure section — which post types hold your content and whether they are scanned, which taxonomies sort it, articles left only in the default category, articles in no cluster and the largest cluster, with a hint where something stands out.
+* Text Match: article titles with an apostrophe or quotes no longer show as HTML codes.
+
+= 1.1.0 =
+* First release on WordPress.org. Site map, cluster graphs and health scores; Focus next, Improve this cluster and Improve this article; Text Match with one-click links and undo; pillars you can choose yourself; SEO Titles with Restore original; cluster quick view; clusters from any taxonomy and pages as clusters; WooCommerce and multilingual sites (Polylang, WPML).
+
+The development history before 1.1.0 is at https://clusterlane.app/changelog/.
Index: /clusterlane/tags/1.3.2/uninstall.php
===================================================================
--- /clusterlane/tags/1.3.2/uninstall.php	(revision 3751027)
+++ /clusterlane/tags/1.3.2/uninstall.php	(revision 3751027)
@@ -0,0 +1,14 @@
+<?php
+/**
+ * Clusterlane uninstall (free version)
+ *
+ * Runs when the plugin is deleted from wp-admin → Plugins → Delete and removes
+ * everything Clusterlane stores — see intelligence/uninstall-data.php. The Pro
+ * build has no uninstall.php: Freemius calls the same cleanup from its own
+ * uninstall hook. Deactivating does NOT run this — data is kept on deactivate.
+ */
+
+if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) exit;
+
+require_once __DIR__ . '/intelligence/uninstall-data.php';
+clusterlane_uninstall_data( (string) WP_UNINSTALL_PLUGIN );
