[object Object]

← back to Exo

backport the dashboard to staging

09593c5e85595af72ab10e76b105899cdaf7c8e1 · 2025-12-17 12:22:22 +0000 · Evan Quiney

Files touched

Diff

commit 09593c5e85595af72ab10e76b105899cdaf7c8e1
Author: Evan Quiney <evanev7@gmail.com>
Date:   Wed Dec 17 12:22:22 2025 +0000

    backport the dashboard to staging
---
 .gitignore                                         |   31 +-
 dashboard/index.html                               | 3343 --------------------
 dashboard/package-lock.json                        | 3058 ++++++++++++++++++
 dashboard/package.json                             |   33 +
 dashboard/src/app.css                              |  322 ++
 dashboard/src/app.d.ts                             |   14 +
 dashboard/src/app.html                             |   14 +
 .../src/lib/components/ChatAttachments.svelte      |   75 +
 dashboard/src/lib/components/ChatForm.svelte       |  398 +++
 dashboard/src/lib/components/ChatMessages.svelte   |  462 +++
 dashboard/src/lib/components/ChatSidebar.svelte    |  430 +++
 dashboard/src/lib/components/HeaderNav.svelte      |   57 +
 dashboard/src/lib/components/ModelCard.svelte      |  660 ++++
 dashboard/src/lib/components/TopologyGraph.svelte  |  971 ++++++
 dashboard/src/lib/components/index.ts              |    7 +
 dashboard/src/lib/stores/app.svelte.ts             | 1395 ++++++++
 dashboard/src/lib/types/files.ts                   |  169 +
 dashboard/src/routes/+layout.svelte                |   15 +
 dashboard/src/routes/+page.svelte                  | 1840 +++++++++++
 dashboard/src/routes/downloads/+page.svelte        |  441 +++
 dashboard/static/exo-logo.png                      |  Bin 0 -> 1655 bytes
 dashboard/static/favicon.ico                       |  Bin 0 -> 4286 bytes
 dashboard/svelte.config.js                         |   28 +
 dashboard/tsconfig.json                            |   15 +
 dashboard/vite.config.ts                           |   16 +
 flake.nix                                          |    4 +-
 justfile                                           |   12 +
 src/exo/master/api.py                              |  228 +-
 src/exo/master/main.py                             |   21 +-
 src/exo/master/placement.py                        |   21 +-
 src/exo/master/tests/test_master.py                |    4 +-
 src/exo/master/tests/test_placement.py             |   32 +-
 src/exo/shared/models/model_cards.py               |  297 +-
 src/exo/shared/types/api.py                        |   38 +-
 src/exo/shared/types/commands.py                   |    9 +-
 src/exo/shared/types/memory.py                     |    5 +
 src/exo/utils/dashboard_path.py                    |   45 +
 37 files changed, 10975 insertions(+), 3535 deletions(-)

diff --git a/.gitignore b/.gitignore
index feae4364..befc8b3b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,31 +1,24 @@
-*/__pycache__
-__pycache__
-*.so
+# gitingest
+digest.txt
 
-hosts.json
-hosts*.json
-nodes.json
+# python
+**/__pycache__
 
-# hide direnv stuff
+# nix
 .direnv/
 
-build/
-dist/
 
+# xcode / macos
 *.xcuserstate
-.DS_Store
-*/.DS_Store
+**/.DS_Store
 
-# for the gitingest enthusiasts
-digest.txt
 
-# Rust
+# rust
 target/
-## These are backup files generated by rustfmt
 **/*.rs.bk
-## MSVC Windows builds of rustc generate these, which store debugging information
 *.pdb
 
-## Generated by cargo mutants
-## Contains mutation testing data
-**/mutants.out*/
+# svelte
+dashboard/build/
+dashboard/node_modules/
+dashboard/.svelte-kit/
diff --git a/dashboard/index.html b/dashboard/index.html
deleted file mode 100644
index 896b79a9..00000000
--- a/dashboard/index.html
+++ /dev/null
@@ -1,3343 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>EXO</title>
-    <style>
-        :root {
-            --exo-black: #121212;
-            --exo-dark-gray: #1E1E1E;
-            --exo-medium-gray: #2C2C2C;
-            --exo-light-gray: #B3B3B3;
-            --exo-yellow: #FFD700;
-            --exo-yellow-darker: #cca300;
-            --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
-        }
-
-        body {
-            background-color: var(--exo-black);
-            color: var(--exo-light-gray);
-            font-family: var(--font-family);
-            margin: 0;
-            padding: 40px 20px 20px 20px;
-            display: flex;
-            flex-direction: column;
-            align-items: center;
-        }
-
-        .dashboard-header {
-            width: 100%;
-            max-width: 1200px;
-            margin-bottom: 15px;
-            margin-top: 20px;
-            display: grid;
-            grid-template-columns: 1fr auto 1fr;
-            align-items: flex-end;
-            gap: 20px;
-        }
-
-        .dashboard-header h1 {
-            font-size: 2.5em;
-            color: var(--exo-yellow);
-            margin: 0;
-            font-weight: 600;
-            line-height: 1;
-        }
-        .dashboard-header h1 .logo-text {
-            font-weight: bold;
-        }
-        .dashboard-header p {
-            font-size: 1em;
-            color: var(--exo-light-gray);
-            margin-top: 5px;
-            margin-bottom: 0;
-            line-height: 1;
-        }
-        .dashboard-header .last-updated {
-            font-size: 0.8em;
-            color: var(--exo-medium-gray);
-            margin-top: 10px;
-            margin-bottom: 0;
-            line-height: 1;
-        }
-
-        .header-left {
-            display: flex;
-            flex-direction: column;
-        }
-
-        .header-center {
-            display: flex;
-            flex-direction: column;
-            align-items: center;
-            justify-content: center;
-        }
-
-        .header-right {
-            display: flex;
-            justify-content: flex-end;
-        }
-
-        .header-instances-button {
-            background-color: transparent;
-            border: 1px solid var(--exo-medium-gray);
-            color: var(--exo-light-gray);
-            font-family: var(--font-family);
-            font-size: 14px;
-            padding: 8px 16px;
-            cursor: pointer;
-            border-radius: 4px;
-            transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
-            line-height: 1;
-        }
-
-        .header-instances-button:hover {
-            background-color: var(--exo-medium-gray);
-            color: var(--exo-yellow);
-            border-color: var(--exo-yellow);
-        }
-
-        .header-instances-button.active {
-            background-color: var(--exo-yellow);
-            color: var(--exo-black);
-            border-color: var(--exo-yellow);
-        }
-
-        /* Removed .node-grid and related card styles as we move to SVG graph */
-        /* Styles for the new topology graph */
-        #topologyGraphContainer {
-            width: 100%;
-            max-width: 1200px; /* Max width consistent with header */
-            height: 70vh; /* Viewport height, can be adjusted */
-            min-height: 500px; /* Minimum height */
-            background-color: var(--exo-dark-gray); /* Dark background for the graph area */
-            border-radius: 8px;
-            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
-            margin-top: 5px;
-            position: relative; /* For potential absolute positioning of elements within */
-        }
-
-        .graph-node {
-            cursor: pointer;
-        }
-        .graph-node circle {
-            stroke: var(--exo-medium-gray);
-            stroke-width: 1.5px;
-            fill: var(--exo-medium-gray);
-            cursor: pointer;
-            transition: fill 0.2s ease, stroke-width 0.2s ease;
-        }
-        .graph-node circle:hover {
-            fill: var(--exo-yellow-darker);
-            stroke-width: 3px;
-        }
-        .graph-node text {
-            fill: var(--exo-light-gray);
-            font-size: 10px;
-            text-anchor: middle;
-            pointer-events: none; /* So text doesn't interfere with circle hover */
-            font-family: var(--font-family);
-        }
-        .graph-node .node-info-text {
-            font-size: 8px;
-            fill: var(--exo-light-gray);
-            text-anchor: middle;
-            pointer-events: none;
-        }
-        .graph-node .node-memory-text {
-            font-size: 15px;
-            fill: #FFFFFF;
-            text-anchor: middle;
-            pointer-events: none;
-            font-weight: 500;
-        }
-        .graph-node .gpu-temp-on-bar {
-            font-size: 16px; /* Increased from 14px */
-            fill: #FFFFFF; /* Changed to white */
-            text-anchor: middle;
-            dominant-baseline: central;
-            pointer-events: none;
-            font-weight: bold;
-        }
-        .graph-link {
-            stroke: var(--exo-light-gray);
-            stroke-width: 1px;
-            opacity: 0.8;
-            stroke-dasharray: 4, 4; /* 4px dash, 4px gap */
-            animation: flowAnimation 0.75s linear infinite;
-        }
-
-        @keyframes flowAnimation {
-            from {
-                stroke-dashoffset: 0;
-            }
-            to {
-                stroke-dashoffset: -8; /* Negative of (dash + gap) for forward flow */
-            }
-        }
-
-        .edge-label {
-            font-size: 10px;
-            fill: var(--exo-light-gray);
-            text-anchor: middle;
-            pointer-events: none;
-            font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
-            opacity: 0.95;
-        }
-
-        .edge-label-bg {
-            fill: var(--exo-dark-gray);
-            opacity: 0.85;
-        }
-
-        .node-info-grid {
-            display: grid;
-            grid-template-columns: repeat(2, 1fr);
-            gap: 10px 15px;
-        }
-
-        .info-item {
-            font-size: 0.9em;
-        }
-
-        .info-item .label {
-            display: block;
-            font-size: 0.8em;
-            color: var(--exo-light-gray);
-            margin-bottom: 3px;
-            opacity: 0.7;
-        }
-
-        .info-item .value {
-            font-weight: 500;
-            color: #E0E0E0;
-        }
-
-        .info-item .value.accent {
-            color: var(--exo-yellow);
-            font-weight: bold;
-        }
-
-        .progress-bar-container {
-            width: 100%;
-            background-color: var(--exo-medium-gray);
-            border-radius: 4px;
-            height: 10px;
-            overflow: hidden;
-            margin-top: 4px;
-        }
-
-        .progress-bar {
-            height: 100%;
-            background-color: var(--exo-yellow);
-            border-radius: 4px;
-            transition: width 0.3s ease; /* Keep transition for smoothness */
-        }
-
-        .node-footer {
-            font-size: 0.8em;
-            color: var(--exo-light-gray);
-            opacity: 0.6;
-            margin-top: auto;
-            padding-top: 10px;
-            border-top: 1px solid var(--exo-medium-gray);
-            text-align: right;
-        }
-
-        .loading-message, .error-message {
-            text-align: center;
-            font-size: 1.2em;
-            padding: 20px;
-            color: var(--exo-light-gray);
-        }
-        .error-message {
-            color: #F44336;
-        }
-
-        /* Styles for Node Detail Panel */
-        #nodeDetailPanel {
-            position: fixed;
-            right: -400px; /* Start off-screen */
-            top: 0;
-            width: 380px;
-            height: 100vh;
-            background-color: var(--exo-dark-gray);
-            box-shadow: -5px 0px 15px rgba(0,0,0,0.3);
-            padding: 20px;
-            overflow-y: auto;
-            transition: right 0.3s ease-in-out;
-            z-index: 1000;
-            border-left: 1px solid var(--exo-medium-gray);
-            transform: translateX(100%);
-            opacity: 0;
-            visibility: hidden;
-        }
-        #nodeDetailPanel.visible {
-            right: 0;
-            transform: translateX(0);
-            opacity: 1;
-            visibility: visible;
-        }
-        #nodeDetailPanel h2 {
-            color: var(--exo-yellow);
-            margin-top: 0;
-            margin-bottom: 5px;
-            font-size: 1.6em;
-        }
-        #nodeDetailPanel .detail-node-id {
-            font-size: 0.7em;
-            color: var(--exo-medium-gray);
-            margin-bottom: 15px;
-            word-break: break-all;
-            opacity: 0.7;
-        }
-        #nodeDetailPanel .info-item {
-            margin-bottom: 12px;
-            font-size: 0.95em;
-        }
-        #nodeDetailPanel .info-item .label {
-            display: block;
-            font-size: 0.8em;
-            color: var(--exo-light-gray);
-            margin-bottom: 4px;
-            opacity: 0.7;
-        }
-        #nodeDetailPanel .info-item .value {
-            font-weight: 500;
-            color: #E0E0E0;
-        }
-        #nodeDetailPanel .info-item .value.accent {
-            color: var(--exo-yellow);
-            font-weight: bold;
-        }
-        #nodeDetailPanel .progress-bar-container {
-            width: 100%;
-            background-color: var(--exo-medium-gray);
-            border-radius: 4px;
-            height: 10px;
-            overflow: hidden;
-            margin-top: 4px;
-        }
-        #nodeDetailPanel .progress-bar {
-            height: 100%;
-            background-color: var(--exo-yellow);
-            border-radius: 4px;
-            transition: width 0.3s ease;
-        }
-        #closeDetailPanel {
-            position: absolute;
-            top: 15px;
-            right: 15px;
-            font-size: 1.5em;
-            color: var(--exo-light-gray);
-            cursor: pointer;
-            padding: 5px;
-            line-height: 1;
-        }
-        #closeDetailPanel:hover {
-            color: var(--exo-yellow);
-        }
-
-
-
-        /* Sidebar styles */
-        .sidebar {
-            position: fixed;
-            top: 0;
-            left: -350px;
-            width: 350px;
-            height: 100vh;
-            background-color: var(--exo-dark-gray);
-            border-right: 1px solid var(--exo-medium-gray);
-            box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
-            transition: left 0.3s ease;
-            z-index: 999;
-            overflow-y: auto;
-            visibility: hidden;
-            opacity: 0;
-        }
-
-        .sidebar.open {
-            left: 0;
-            visibility: visible;
-            opacity: 1;
-        }
-
-        .sidebar-header {
-            padding: 20px;
-            border-bottom: 1px solid var(--exo-medium-gray);
-            background-color: var(--exo-medium-gray);
-        }
-
-        .sidebar-header h3 {
-            margin: 0;
-            color: var(--exo-yellow);
-            font-size: 18px;
-            font-weight: 600;
-        }
-
-        .sidebar-content {
-            padding: 20px;
-        }
-
-        /* Instance list styles */
-        .instance-item {
-            background-color: var(--exo-medium-gray);
-            border-radius: 6px;
-            padding: 15px;
-            margin-bottom: 12px;
-            border-left: 4px solid var(--exo-yellow);
-            transition: background-color 0.2s ease;
-            position: relative;
-        }
-
-        .instance-color-indicator {
-            position: absolute;
-            top: 15px;
-            left: -4px;
-            width: 4px;
-            height: calc(100% - 30px);
-            border-radius: 0 2px 2px 0;
-        }
-
-        .instance-item:hover {
-            background-color: #353535;
-        }
-
-        .instance-header {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            margin-bottom: 8px;
-        }
-
-        .instance-id {
-            font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
-            font-size: 12px;
-            color: var(--exo-light-gray);
-            background-color: var(--exo-black);
-            padding: 2px 6px;
-            border-radius: 3px;
-            margin-right: 10px;
-        }
-
-        .instance-status {
-            font-size: 12px;
-            padding: 2px 8px;
-            border-radius: 12px;
-            font-weight: 500;
-        }
-
-        .instance-status.active {
-            background-color: #4ade80;
-            color: var(--exo-black);
-        }
-
-        .instance-status.inactive {
-            background-color: #ef4444;
-            color: white;
-        }
-
-        .instance-status.downloading {
-            background-color: #f59e0b;
-            color: var(--exo-black);
-        }
-        /* New runner-status aware pills */
-        .instance-status.starting {
-            background-color: #3b82f6; /* blue */
-            color: var(--exo-black);
-        }
-
-        .instance-status.loaded {
-            background-color: #2dd4bf; /* teal */
-            color: var(--exo-black);
-        }
-
-        .instance-status.running {
-            background-color: #4ade80; /* green */
-            color: var(--exo-black);
-        }
-
-        .instance-status.failed {
-            background-color: #ef4444; /* red */
-            color: white;
-        }
-
-        .instance-delete-button {
-            background-color: #ef4444;
-            color: white;
-            border: none;
-            border-radius: 4px;
-            padding: 4px 8px;
-            font-size: 11px;
-            cursor: pointer;
-            transition: background-color 0.2s ease;
-            margin-left: 8px;
-        }
-
-        .instance-delete-button:hover {
-            background-color: #dc2626;
-        }
-
-        .instance-actions {
-            display: flex;
-            align-items: center;
-        }
-
-        .instance-info {
-            display: flex;
-            align-items: center;
-        }
-
-        .instance-model {
-            font-size: 14px;
-            font-weight: 500;
-            color: var(--exo-yellow);
-            margin-bottom: 8px;
-        }
-
-        .instance-strategy {
-            font-size: 13px;
-            color: var(--exo-light-gray);
-            margin-bottom: 8px;
-        }
-
-        .instance-strategy-value {
-            font-weight: 600;
-            color: var(--exo-yellow);
-        }
-
-        .instance-details {
-            font-size: 12px;
-            color: var(--exo-light-gray);
-        }
-
-
-
-        .progress-bar-container {
-            background-color: var(--exo-black);
-            border-radius: 8px;
-            height: 6px;
-            flex-grow: 1;
-            overflow: hidden;
-        }
-
-        .progress-bar {
-            background-color: #3b82f6;
-            height: 100%;
-            border-radius: 8px;
-            transition: width 0.3s ease;
-        }
-
-
-        /* Overall download summary styles */
-        .overall-download-summary {
-            margin-top: 10px;
-            margin-bottom: 8px;
-        }
-
-        .overall-download-header {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            margin-bottom: 4px;
-        }
-
-        .overall-download-label {
-            font-size: 11px;
-            font-weight: 500;
-            color: var(--exo-light-gray);
-            opacity: 0.7;
-        }
-
-        .overall-download-percent {
-            font-size: 11px;
-            font-weight: 500;
-            color: var(--exo-light-gray);
-            opacity: 0.7;
-            font-variant-numeric: tabular-nums;
-        }
-
-        .overall-download-stats {
-            font-size: 10px;
-            color: var(--exo-light-gray);
-            margin-top: 4px;
-            opacity: 0.6;
-        }
-
-        /* Per-node download summary styles */
-        .node-download-summary {
-            margin-top: 12px;
-            padding: 10px;
-            background-color: rgba(0, 0, 0, 0.2);
-            border-radius: 6px;
-            border-left: 3px solid #3b82f6;
-        }
-
-        .node-download-header {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            margin-bottom: 6px;
-        }
-
-        .node-download-name {
-            font-size: 13px;
-            font-weight: 600;
-            color: var(--exo-yellow);
-        }
-
-        .node-download-percent {
-            font-size: 13px;
-            font-weight: 600;
-            color: #3b82f6;
-            font-variant-numeric: tabular-nums;
-        }
-
-        .node-download-stats {
-            font-size: 11px;
-            color: var(--exo-light-gray);
-            margin-top: 6px;
-            margin-bottom: 10px;
-            opacity: 0.9;
-        }
-
-        /* File-level download details */
-        .download-files-list {
-            display: grid;
-            gap: 8px;
-            margin-top: 10px;
-        }
-
-        .download-file {
-            padding: 8px;
-            background-color: rgba(0, 0, 0, 0.3);
-            border: 1px solid var(--exo-medium-gray);
-            border-radius: 6px;
-            box-sizing: border-box;
-            width: 100%;
-            max-width: 100%;
-        }
-
-        .download-file-header {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-            gap: 10px;
-            font-size: 11px;
-            margin-bottom: 6px;
-            width: 100%;
-            max-width: 100%;
-            overflow: hidden;
-        }
-
-        .download-file-name {
-            color: #E0E0E0;
-            font-weight: 500;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-            min-width: 0;
-            flex: 1 1 auto;
-        }
-
-        .download-file-percent {
-            color: var(--exo-light-gray);
-            white-space: nowrap;
-            font-size: 11px;
-            font-variant-numeric: tabular-nums;
-            flex: 0 0 auto;
-        }
-
-        .download-file-subtext {
-            color: var(--exo-light-gray);
-            font-size: 10px;
-            opacity: 0.85;
-            margin-bottom: 6px;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-            max-width: 100%;
-        }
-
-        .download-file .progress-bar-container {
-            width: 100%;
-            max-width: 100%;
-            box-sizing: border-box;
-            height: 5px;
-        }
-
-        .completed-files-section {
-            margin-top: 12px;
-            padding-top: 8px;
-            border-top: 1px solid rgba(255, 255, 255, 0.1);
-        }
-
-        .completed-files-header {
-            font-size: 10px;
-            color: var(--exo-light-gray);
-            opacity: 0.7;
-            margin-bottom: 6px;
-            font-weight: 500;
-        }
-
-        .completed-files-list {
-            display: flex;
-            flex-direction: column;
-            gap: 3px;
-        }
-
-        .completed-file-item {
-            font-size: 10px;
-            color: var(--exo-light-gray);
-            opacity: 0.8;
-            padding: 3px 6px;
-            background-color: rgba(74, 222, 128, 0.1);
-            border-left: 2px solid #4ade80;
-            border-radius: 3px;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-        }
-
-        /* Launch instance section styles */
-        .launch-instance-section {
-            display: flex;
-            flex-direction: column;
-            gap: 12px;
-            margin-bottom: 50px;
-        }
-
-        .launch-label {
-            font-size: 14px;
-            font-weight: 500;
-            color: var(--exo-light-gray);
-            margin-bottom: 4px;
-        }
-
-        .model-select {
-            background: linear-gradient(135deg, #2a2a2a 0%, #3c3c3c 50%, #2a2a2a 100%);
-            color: var(--exo-light-gray);
-            border: 2px solid rgba(255, 215, 0, 0.2);
-            border-radius: 12px;
-            padding: 14px 20px 14px 16px;
-            font-size: 15px;
-            font-family: var(--font-family);
-            font-weight: 500;
-            cursor: pointer;
-            transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-            box-shadow: 
-                0 4px 12px rgba(0, 0, 0, 0.25),
-                inset 0 1px 0 rgba(255, 255, 255, 0.12),
-                inset 0 -1px 0 rgba(0, 0, 0, 0.1);
-            position: relative;
-            appearance: none;
-            width: 100%;
-            min-height: 48px;
-            background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23FFD700' d='M6 8.5L2.5 5h7z'/%3E%3C/svg%3E");
-            background-position: calc(100% - 16px) center;
-            background-size: 12px 12px;
-            background-repeat: no-repeat;
-        }
-
-        .model-select:hover {
-            background: linear-gradient(135deg, #363636 0%, #484848 50%, #363636 100%);
-            border-color: rgba(255, 215, 0, 0.5);
-            box-shadow: 
-                0 6px 20px rgba(0, 0, 0, 0.3),
-                inset 0 1px 0 rgba(255, 255, 255, 0.15),
-                inset 0 -1px 0 rgba(0, 0, 0, 0.1),
-                0 0 0 1px rgba(255, 215, 0, 0.1);
-            transform: translateY(-2px);
-        }
-
-        .model-select:focus {
-            outline: none;
-            border-color: var(--exo-yellow);
-            box-shadow: 
-                0 0 0 4px rgba(255, 215, 0, 0.25),
-                0 8px 24px rgba(0, 0, 0, 0.4),
-                inset 0 1px 0 rgba(255, 255, 255, 0.2),
-                inset 0 -1px 0 rgba(0, 0, 0, 0.1);
-            background: linear-gradient(135deg, #404040 0%, #525252 50%, #404040 100%);
-            transform: translateY(-1px);
-        }
-
-        .model-select:active {
-            transform: translateY(0);
-            box-shadow: 
-                0 2px 8px rgba(0, 0, 0, 0.3),
-                inset 0 1px 0 rgba(255, 255, 255, 0.1),
-                inset 0 2px 6px rgba(0, 0, 0, 0.2);
-        }
-
-        .model-select:disabled {
-            background: linear-gradient(135deg, #1a1a1a 0%, #222222 100%);
-            color: #555555;
-            border-color: #333333;
-            cursor: not-allowed;
-            transform: none;
-            box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.4);
-            background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555555' d='M6 8.5L2.5 5h7z'/%3E%3C/svg%3E");
-        }
-
-        .model-select option {
-            background-color: var(--exo-dark-gray);
-            color: var(--exo-light-gray);
-            padding: 12px 16px;
-            border: none;
-            font-size: 14px;
-            font-weight: 500;
-        }
-
-        .model-select option:hover {
-            background-color: var(--exo-medium-gray);
-            color: var(--exo-yellow);
-        }
-
-        .model-select option:checked {
-            background-color: var(--exo-yellow);
-            color: var(--exo-black);
-            font-weight: 600;
-        }
-
-        .launch-button {
-            background-color: var(--exo-yellow);
-            color: var(--exo-black);
-            border: none;
-            border-radius: 6px;
-            padding: 12px 16px;
-            font-size: 14px;
-            font-weight: 600;
-            font-family: var(--font-family);
-            cursor: pointer;
-            transition: background-color 0.2s ease;
-        }
-
-        .launch-button:hover:not(:disabled) {
-            background-color: var(--exo-yellow-darker);
-        }
-
-        .launch-button:disabled {
-            background-color: var(--exo-medium-gray);
-            color: var(--exo-light-gray);
-            cursor: not-allowed;
-        }
-
-        .strategy-selector {
-            display: flex;
-            flex-direction: column;
-            gap: 8px;
-        }
-
-        .strategy-options {
-            display: flex;
-            gap: 12px;
-            flex-wrap: wrap;
-        }
-
-        .strategy-option {
-            display: flex;
-            align-items: center;
-            gap: 6px;
-            cursor: pointer;
-            padding: 8px 12px;
-            border-radius: 6px;
-            background-color: var(--exo-dark-gray);
-            border: 2px solid var(--exo-medium-gray);
-            transition: all 0.2s ease;
-            user-select: none;
-        }
-
-        .strategy-option:hover {
-            background-color: var(--exo-medium-gray);
-            border-color: rgba(255, 215, 0, 0.5);
-        }
-
-        .strategy-option input[type="radio"] {
-            appearance: none;
-            width: 16px;
-            height: 16px;
-            border: 2px solid var(--exo-light-gray);
-            border-radius: 50%;
-            cursor: pointer;
-            position: relative;
-            margin: 0;
-            transition: all 0.2s ease;
-        }
-
-        .strategy-option input[type="radio"]:checked {
-            border-color: var(--exo-yellow);
-            background-color: var(--exo-yellow);
-        }
-
-        .strategy-option input[type="radio"]:checked::after {
-            content: '';
-            position: absolute;
-            top: 50%;
-            left: 50%;
-            transform: translate(-50%, -50%);
-            width: 6px;
-            height: 6px;
-            border-radius: 50%;
-            background-color: var(--exo-black);
-        }
-
-        .strategy-option:has(input[type="radio"]:checked) {
-            background-color: rgba(255, 215, 0, 0.15);
-            border-color: var(--exo-yellow);
-        }
-
-        .strategy-option label {
-            cursor: pointer;
-            font-size: 14px;
-            font-weight: 500;
-            color: var(--exo-light-gray);
-            margin: 0;
-        }
-
-        .strategy-option:has(input[type="radio"]:checked) label {
-            color: var(--exo-yellow);
-        }
-
-        .launch-status {
-            font-size: 12px;
-            padding: 8px;
-            border-radius: 4px;
-            text-align: center;
-            display: none;
-        }
-
-        .launch-status.success {
-            background-color: rgba(74, 222, 128, 0.1);
-            color: #4ade80;
-            border: 1px solid #4ade80;
-            display: block;
-        }
-
-        .launch-status.error {
-            background-color: rgba(239, 68, 68, 0.1);
-            color: #ef4444;
-            border: 1px solid #ef4444;
-            display: block;
-        }
-
-        .launch-status.loading {
-            background-color: rgba(255, 215, 0, 0.1);
-            color: var(--exo-yellow);
-            border: 1px solid var(--exo-yellow);
-            display: block;
-        }
-
-        .instance-hosts {
-            margin-top: 8px;
-        }
-
-        .instance-host {
-            display: inline-block;
-            background-color: var(--exo-black);
-            padding: 2px 6px;
-            border-radius: 3px;
-            margin-right: 6px;
-            margin-bottom: 4px;
-            font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
-            font-size: 11px;
-        }
-
-        .no-instances {
-            text-align: center;
-            color: var(--exo-light-gray);
-            font-style: italic;
-            margin-top: 40px;
-            margin-bottom: 30px;
-        }
-
-
-
-    </style>
-</head>
-<body>
-
-
-
-    <!-- Sidebar -->
-    <div class="sidebar" id="instancesSidebar">
-        <div class="sidebar-header">
-            <h3>Running Instances</h3>
-        </div>
-        <div class="sidebar-content" id="instancesList">
-            <div class="no-instances">Loading instances...</div>
-        </div>
-        
-        <div class="sidebar-header">
-            <h3>Launch Instance</h3>
-        </div>
-        <div class="sidebar-content">
-            <div class="launch-instance-section">
-                <label for="modelSelect" class="launch-label">Select Model:</label>
-                <select id="modelSelect" class="model-select">
-                    <option value="">Loading models...</option>
-                </select>
-                
-                <div class="strategy-selector">
-                    <label class="launch-label">Sharding:</label>
-                    <div class="strategy-options">
-                        <div class="strategy-option">
-                            <input type="radio" id="shardingPipeline" name="sharding" value="Pipeline">
-                            <label for="shardingPipeline">Pipeline</label>
-                        </div>
-                        <div class="strategy-option">
-                            <input type="radio" id="shardingTensor" name="sharding" value="Tensor" checked>
-                            <label for="shardingTensor">Tensor</label>
-                        </div>
-                    </div>
-                </div>
-                
-                <div class="strategy-selector">
-                    <label class="launch-label">Instance Type:</label>
-                    <div class="strategy-options">
-                        <div class="strategy-option">
-                            <input type="radio" id="instanceMlxRing" name="instance_meta" value="MlxRing">
-                            <label for="instanceMlxRing">MLX Ring</label>
-                        </div>
-                        <div class="strategy-option">
-                            <input type="radio" id="instanceMlxIbv" name="instance_meta" value="MlxIbv" checked>
-                            <label for="instanceMlxIbv">MLX IBV</label>
-                        </div>
-                    </div>
-                </div>
-                
-                <div class="strategy-selector">
-                    <label class="launch-label">Minimum Nodes:</label>
-                    <div class="strategy-options" id="minNodesOptions">
-                        <div class="strategy-option">
-                            <input type="radio" id="minNodes1" name="min_nodes" value="1" checked>
-                            <label for="minNodes1">1</label>
-                        </div>
-                    </div>
-                </div>
-                
-                <button id="launchInstanceButton" class="launch-button" disabled>Launch Instance</button>
-                <div id="launchStatus" class="launch-status"></div>
-            </div>
-        </div>
-    </div>
-
-    <div class="dashboard-header">
-        <div class="header-left">
-            <!-- Left section: empty or can be used for future content -->
-        </div>
-        <div class="header-center">
-            <h1><img src="exo-logo.png" alt="EXO logo" height="48" /></h1>
-            <p class="last-updated" id="lastUpdated">Fetching data...</p>
-        </div>
-        <div class="header-right">
-            <button class="header-instances-button" id="instancesMenuButton">Instances</button>
-        </div>
-    </div>
-
-    <!-- Replaced node-grid with SVG container for topology graph -->
-    <svg id="topologyGraphContainer"></svg>
-
-    <div id="nodeDetailPanel">
-        <span id="closeDetailPanel" title="Close">&times;</span>
-        <h2 id="detailFriendlyName">Node Details</h2>
-        <div id="detailNodeId" class="detail-node-id"></div>
-        <div id="detailContent">
-            <!-- Info items will be populated here -->
-        </div>
-    </div>
-
-    <script>
-        // Helper to convert RGB string for box-shadow
-        function setRgbVar(cssVarName, hexColor) {
-            const r = parseInt(hexColor.slice(1, 3), 16);
-            const g = parseInt(hexColor.slice(3, 5), 16);
-            const b = parseInt(hexColor.slice(5, 7), 16);
-            document.documentElement.style.setProperty(`--${cssVarName}-rgb`, `${r}, ${g}, ${b}`);
-        }
-        setRgbVar('exo-yellow', getComputedStyle(document.documentElement).getPropertyValue('--exo-yellow').trim());
-
-        // Generate a consistent color for an instance ID using a simple hash
-        function generateInstanceColor(instanceId) {
-            if (!instanceId) return '#888888';
-            
-            // Simple hash function
-            let hash = 0;
-            for (let i = 0; i < instanceId.length; i++) {
-                hash = instanceId.charCodeAt(i) + ((hash << 5) - hash);
-            }
-            
-            // Convert to HSL for better color distribution
-            // Use high saturation and medium lightness for vibrant, distinguishable colors
-            const hue = Math.abs(hash % 360);
-            const saturation = 65 + (Math.abs(hash >> 8) % 20); // 65-85%
-            const lightness = 55 + (Math.abs(hash >> 16) % 15); // 55-70%
-            
-            return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
-        }
-
-        const topologyGraphContainer = document.getElementById('topologyGraphContainer');
-        const lastUpdatedElement = document.getElementById('lastUpdated');
-        const nodeDetailPanel = document.getElementById('nodeDetailPanel');
-        const closeDetailPanelButton = document.getElementById('closeDetailPanel');
-        const detailFriendlyName = document.getElementById('detailFriendlyName');
-        const detailNodeId = document.getElementById('detailNodeId');
-        const detailContent = document.getElementById('detailContent');
-        
-        // Sidebar elements
-        const instancesMenuButton = document.getElementById('instancesMenuButton');
-        const instancesSidebar = document.getElementById('instancesSidebar');
-        const instancesList = document.getElementById('instancesList');
-        
-        // Launch instance elements
-        const modelSelect = document.getElementById('modelSelect');
-        const launchInstanceButton = document.getElementById('launchInstanceButton');
-        const launchStatus = document.getElementById('launchStatus');
-        const minNodesOptions = document.getElementById('minNodesOptions');
-
-        const USE_MOCK_DATA = false; // <<< FLAG TO TOGGLE MOCK DATA
-        let currentlySelectedNodeId = null; // To store the ID of the currently selected node
-        let nodeIdToFriendlyName = {}; // Map nodeId -> friendly name for download sections
-        let instanceIdToColor = {}; // Map instanceId -> color for visual coding
-        let connectionToInstances = {}; // Map "nodeA|nodeB" -> [instanceIds] using that connection
-        let currentNodeCount = 1; // Track the current number of nodes in topology
-
-        const API_ENDPOINT = window.location.origin + window.location.pathname.replace(/\/$/, "") + '/state';
-        const REFRESH_INTERVAL = 1000; // 1 second
-
-        // SVG Path for Apple Logo (scaled to roughly 20x24 units)
-        // const APPLE_LOGO_PATH = "M15.36,10.08c0-2.84-1.92-4.48-4.8-4.48-2.36,0-4.44,1.44-5.64,2.84-1.48,1.68-2.44,4-2.44,6.52,0,2.92,1.72,4.6,4.76,4.6,2.48,0,4.64-1.52,5.84-2.96,1.36-1.6,2.32-3.96,2.32-6.52ZM11.48,0c2.4,0,4.24.48,5.48,1.44a5.07,5.07,0,0,1,1.44,3.88c0,2.04-.8,3.56-2.44,4.6-1.68,1.08-3.48,1.6-5.44,1.6-.96,0-2.12-.12-3.48-.32V3.08A11.29,11.29,0,0,1,11.48,0Z M9.04,13.2A5.31,5.31,0,0,0,11.4,14c1.6,0,2.68-.6,2.68-1.8,0-.92-.64-1.48-1.92-1.68l-1.6-.24c-1.8-.28-2.92-1.08-2.92-2.56,0-1.4,1.04-2.44,3-2.44a4.37,4.37,0,0,1,2.88.88l.88-1.72a5.73,5.73,0,0,0-3.8-1.28c-2.48,0-4.04.96-4.04,2.96,0,1.16.68,1.92,2,2.24l1.64.32c2.12.36,3.24,1.12,3.24,2.68,0,1.64-1.24,2.72-3.4,2.72a5.25,5.25,0,0,1-3.48-1.2Z";
-        // Replaced with accurate path data from SVG file
-        const APPLE_LOGO_PATH_SIMPLE = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
-
-        // Native dimensions of the logo path approx
-        const LOGO_NATIVE_WIDTH = 814;
-        const LOGO_NATIVE_HEIGHT = 1000;
-
-        // Helper function to describe an SVG arc (pie slice)
-        function describePieSlice(cx, cy, radius, startAngleDeg, endAngleDeg) {
-            const startAngleRad = (startAngleDeg - 90) * Math.PI / 180.0; // Subtract 90 to start from top
-            const endAngleRad = (endAngleDeg - 90) * Math.PI / 180.0;
-            const largeArcFlag = endAngleDeg - startAngleDeg <= 180 ? "0" : "1";
-
-            const startX = cx + radius * Math.cos(startAngleRad);
-            const startY = cy + radius * Math.sin(startAngleRad);
-            const endX = cx + radius * Math.cos(endAngleRad);
-            const endY = cy + radius * Math.sin(endAngleRad);
-
-            // For a full circle, a single arc won't work perfectly due to start/end points being the same.
-            // So, if it's very close to a full circle, we make it a tiny bit less to ensure it renders.
-            if (Math.abs(endAngleDeg - startAngleDeg) >= 359.99) {
-                 // Draw two semi-circles instead for a full circle effect if needed,
-                 // or simply use a circle element. For now, let's cap it.
-                 // This pie slice is for partial fill, so full isn't the primary goal here.
-            }
-
-
-            const d = [
-                "M", cx, cy, // Move to center
-                "L", startX, startY, // Line to start of arc
-                "A", radius, radius, 0, largeArcFlag, 1, endX, endY, // Arc
-                "Z" // Close path (back to center)
-            ].join(" ");
-
-            return d;
-        }
-
-        // Helper function to determine GPU bar color based on temperature (continuous gradient)
-        function getGpuBarColor(temp) {
-            if (isNaN(temp) || temp === null) return 'var(--exo-light-gray)'; // Default for N/A temp
-
-            const coolTemp = 45; // Temp for pure blue (Changed from 30)
-            const midTemp = 57.5;  // Temp for pure yellow (approx mid of 40-65)
-            const hotTemp = 75;  // Temp for pure red
-
-            const coolColor = { r: 93, g: 173, b: 226 };  // #5DADE2 (Blue)
-            const midColor = { r: 255, g: 215, b: 0 };   // var(--exo-yellow) #FFD700
-            const hotColor = { r: 244, g: 67, b: 54 };    // #F44336 (Red)
-
-            let r, g, b;
-
-            if (temp <= coolTemp) {
-                ({ r, g, b } = coolColor);
-            } else if (temp > coolTemp && temp <= midTemp) {
-                const ratio = (temp - coolTemp) / (midTemp - coolTemp);
-                r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
-                g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
-                b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
-            } else if (temp > midTemp && temp < hotTemp) {
-                const ratio = (temp - midTemp) / (hotTemp - midTemp);
-                r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
-                g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
-                b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
-            } else { // temp >= hotTemp
-                ({ r, g, b } = hotColor);
-            }
-
-            return `rgb(${r}, ${g}, ${b})`;
-        }
-
-        function formatBytes(bytes, decimals = 2) {
-            if (!bytes || bytes === 0) return '0 Bytes';
-            const k = 1024;
-            const dm = decimals < 0 ? 0 : decimals;
-            const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
-            const i = Math.floor(Math.log(bytes) / Math.log(k));
-            return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
-        }
-
-        function timeSince(timestampSeconds) {
-            const now = Date.now() / 1000;
-            const secondsPast = Math.floor(now - timestampSeconds);
-
-            if (secondsPast < 5) {
-                return 'just now';
-            }
-            if (secondsPast < 60) {
-                return `${secondsPast}s ago`;
-            }
-            if (secondsPast < 3600) {
-                return `${Math.floor(secondsPast / 60)}m ago`;
-            }
-            if (secondsPast <= 86400) {
-                return `${Math.floor(secondsPast / 3600)}h ago`;
-            }
-            const days = Math.floor(secondsPast / 86400);
-            return days + (days === 1 ? ' day ago' : ' days ago');
-        }
-
-        // --- Download formatting helpers ---
-        function bytesFromValue(value) {
-            if (typeof value === 'number') return value;
-            if (!value || typeof value !== 'object') return 0;
-            if (typeof value.in_bytes === 'number') return value.in_bytes;
-            if (typeof value.inBytes === 'number') return value.inBytes;
-            return 0;
-        }
-
-        function formatDurationMs(ms) {
-            if (ms == null || isNaN(ms) || ms < 0) return '—';
-            const totalSeconds = Math.round(ms / 1000);
-            const s = totalSeconds % 60;
-            const m = Math.floor(totalSeconds / 60) % 60;
-            const h = Math.floor(totalSeconds / 3600);
-            if (h > 0) return `${h}h ${m}m ${s}s`;
-            if (m > 0) return `${m}m ${s}s`;
-            return `${s}s`;
-        }
-
-        function formatPercent(value, digits = 2) {
-            if (value == null || isNaN(value)) return '0.00%';
-            return `${value.toFixed(digits)}%`;
-        }
-
-        function formatBytesPerSecond(bps) {
-            if (bps == null || isNaN(bps) || bps < 0) return '0 B/s';
-            return `${formatBytes(bps)}/s`;
-        }
-
-        // Sidebar toggle functionality
-        let sidebarOpen = false;
-
-        function toggleSidebar() {
-            sidebarOpen = !sidebarOpen;
-            instancesSidebar.classList.toggle('open', sidebarOpen);
-            instancesMenuButton.classList.toggle('active', sidebarOpen);
-        }
-
-        // Edge IP display flag (can be toggled from console)
-        window.exoShowEdgeIPs = false;
-        
-        // Debug flag for download tracking (can be toggled from console)
-        window.exoDebugDownloads = false;
-        
-        // Helper function to toggle IP display (accessible from console)
-        window.toggleEdgeIPs = function() {
-            window.exoShowEdgeIPs = !window.exoShowEdgeIPs;
-            console.log(`Edge IP display ${window.exoShowEdgeIPs ? 'enabled' : 'disabled'}`);
-            return window.exoShowEdgeIPs;
-        };
-        
-        // Helper function to toggle download debugging (accessible from console)
-        window.toggleDownloadDebug = function() {
-            window.exoDebugDownloads = !window.exoDebugDownloads;
-            console.log(`Download debugging ${window.exoDebugDownloads ? 'enabled' : 'disabled'}`);
-            return window.exoDebugDownloads;
-        };
-
-        // Fetch available models and populate dropdown
-        async function fetchAndPopulateModels() {
-            try {
-                const response = await fetch(window.location.origin + '/models');
-                if (!response.ok) {
-                    throw new Error(`Failed to fetch models: ${response.status}`);
-                }
-                const data = await response.json();
-                
-                // Clear existing options
-                modelSelect.innerHTML = '';
-                
-                if (data.data && data.data.length > 0) {
-                    // Add default option
-                    const defaultOption = document.createElement('option');
-                    defaultOption.value = '';
-                    defaultOption.textContent = 'Select a model...';
-                    modelSelect.appendChild(defaultOption);
-                    
-                    // Add models
-                    data.data.forEach(model => {
-                        const option = document.createElement('option');
-                        option.value = model.id;
-                        option.textContent = model.name || model.id;
-                        modelSelect.appendChild(option);
-                    });
-                    
-                    launchInstanceButton.disabled = false;
-                } else {
-                    const noModelsOption = document.createElement('option');
-                    noModelsOption.value = '';
-                    noModelsOption.textContent = 'No models available';
-                    modelSelect.appendChild(noModelsOption);
-                }
-            } catch (error) {
-                console.error('Error fetching models:', error);
-                modelSelect.innerHTML = '<option value="">Error loading models</option>';
-            }
-        }
-
-        // Show launch status message
-        function showLaunchStatus(message, type) {
-            launchStatus.textContent = message;
-            launchStatus.className = `launch-status ${type}`;
-            
-            if (type !== 'loading') {
-                setTimeout(() => {
-                    launchStatus.className = 'launch-status';
-                }, 5000);
-            }
-        }
-
-        // Launch instance
-        async function launchInstance() {
-            const selectedModelId = modelSelect.value;
-            console.log("selectedModelId", selectedModelId);
-            if (!selectedModelId) {
-                showLaunchStatus('Please select a model', 'error');
-                return;
-            }
-
-            const selectedSharding = document.querySelector('input[name="sharding"]:checked').value;
-            const selectedInstanceMeta = document.querySelector('input[name="instance_meta"]:checked').value;
-            const minNodesRadio = document.querySelector('input[name="min_nodes"]:checked');
-            const minNodes = minNodesRadio ? parseInt(minNodesRadio.value, 10) : 1;
-            console.log("selectedSharding", selectedSharding);
-            console.log("selectedInstanceMeta", selectedInstanceMeta);
-            console.log("minNodes", minNodes);
-
-            try {
-                showLaunchStatus('Launching instance...', 'loading');
-                launchInstanceButton.disabled = true;
-
-                const response = await fetch(window.location.origin + '/instance', {
-                    method: 'POST',
-                    headers: {
-                        'Content-Type': 'application/json',
-                    },
-                    body: JSON.stringify({ 
-                        model_id: selectedModelId,
-                        sharding: selectedSharding,
-                        instance_meta: selectedInstanceMeta,
-                        min_nodes: minNodes
-                    })
-                });
-
-                if (!response.ok) {
-                    const errorText = await response.text();
-                    throw new Error(`Failed to launch instance: ${response.status} - ${errorText}`);
-                }
-
-                const result = await response.json();
-                showLaunchStatus('Instance launched successfully');
-                
-                // Reset form
-                modelSelect.value = '';
-                
-                // Refresh instances list
-                fetchAndRenderInstances();
-                
-            } catch (error) {
-                console.error('Error launching instance:', error);
-                showLaunchStatus(`Error: ${error.message}`, 'error');
-            } finally {
-                launchInstanceButton.disabled = false;
-            }
-        }
-
-        // Fetch instances data and render
-        async function fetchAndRenderInstances() {
-            try {
-                const response = await fetch(API_ENDPOINT);
-                if (!response.ok) {
-                    throw new Error(`Failed to fetch state: ${response.status}`);
-                }
-                const data = await response.json();
-                
-                if (window.exoDebugDownloads && data.downloads) {
-                    console.log('[Download Debug] State downloads:', data.downloads);
-                    console.log('[Download Debug] Number of nodes with downloads:', Object.keys(data.downloads).length);
-                }
-                
-                renderInstances(data.instances || {}, data.runners || {}, data.downloads || {});
-            } catch (error) {
-                console.error('Error fetching instances:', error);
-                instancesList.innerHTML = '<div class="no-instances">Error loading instances</div>';
-            }
-        }
-
-        // Calculate download status for an instance based on the new downloads structure
-        function calculateInstanceDownloadStatus(instanceWrapped, runners, downloads) {
-            // Unwrap tagged Instance union (MlxRingInstance or MlxIbvInstance)
-            const [_instanceTag, instance] = getTagged(instanceWrapped);
-            if (!instance || typeof instance !== 'object') {
-                return { isDownloading: false, progress: 0, details: [] };
-            }
-            
-            if (!instance.shardAssignments?.runnerToShard) {
-                return { isDownloading: false, progress: 0, details: [] };
-            }
-            
-            if (!downloads || Object.keys(downloads).length === 0) {
-                return { isDownloading: false, progress: 0, details: [] };
-            }
-
-            const pick = (obj, snake, camel, fallback = undefined) => {
-                if (!obj) return fallback;
-                if (obj[snake] !== undefined) return obj[snake];
-                if (obj[camel] !== undefined) return obj[camel];
-                return fallback;
-            };
-
-            function normalizeProgress(progressRaw) {
-                if (!progressRaw) return null;
-                const totalBytes = bytesFromValue(pick(progressRaw, 'total_bytes', 'totalBytes', 0));
-                const downloadedBytes = bytesFromValue(pick(progressRaw, 'downloaded_bytes', 'downloadedBytes', 0));
-                const downloadedBytesThisSession = bytesFromValue(pick(progressRaw, 'downloaded_bytes_this_session', 'downloadedBytesThisSession', 0));
-                const completedFiles = Number(pick(progressRaw, 'completed_files', 'completedFiles', 0)) || 0;
-                const totalFiles = Number(pick(progressRaw, 'total_files', 'totalFiles', 0)) || 0;
-                const speed = Number(pick(progressRaw, 'speed', 'speed', 0)) || 0;
-                const etaMs = Number(pick(progressRaw, 'eta_ms', 'etaMs', 0)) || 0;
-                const filesObj = pick(progressRaw, 'files', 'files', {}) || {};
-                const files = [];
-                Object.keys(filesObj).forEach(name => {
-                    const f = filesObj[name];
-                    if (!f || typeof f !== 'object') return;
-                    const fTotal = bytesFromValue(pick(f, 'total_bytes', 'totalBytes', 0));
-                    const fDownloaded = bytesFromValue(pick(f, 'downloaded_bytes', 'downloadedBytes', 0));
-                    const fSpeed = Number(pick(f, 'speed', 'speed', 0)) || 0;
-                    const fEta = Number(pick(f, 'eta_ms', 'etaMs', 0)) || 0;
-                    const fPct = fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0;
-                    files.push({ name, totalBytes: fTotal, downloadedBytes: fDownloaded, speed: fSpeed, etaMs: fEta, percentage: fPct });
-                });
-                const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
-                return { totalBytes, downloadedBytes, downloadedBytesThisSession, completedFiles, totalFiles, speed, etaMs, files, percentage };
-            }
-
-            // Build reverse mapping from runnerId to nodeId
-            const nodeToRunner = instance.shardAssignments.nodeToRunner || {};
-            const runnerToNode = {};
-            Object.entries(nodeToRunner).forEach(([nodeId, runnerId]) => {
-                runnerToNode[runnerId] = nodeId;
-            });
-
-            const runnerToShard = instance.shardAssignments.runnerToShard || {};
-            const runnerIds = Object.keys(runnerToShard);
-            const details = [];
-            let totalBytes = 0;
-            let downloadedBytes = 0;
-
-            if (window.exoDebugDownloads) {
-                console.log('[Download Debug] Checking downloads for instance:', {
-                    runnerIds,
-                    availableDownloads: Object.keys(downloads),
-                    nodeToRunner
-                });
-            }
-
-            for (const runnerId of runnerIds) {
-                const nodeId = runnerToNode[runnerId];
-                if (!nodeId) {
-                    if (window.exoDebugDownloads) console.log('[Download Debug] No nodeId for runner:', runnerId);
-                    continue;
-                }
-
-                const nodeDownloads = downloads[nodeId];
-                if (!nodeDownloads || !Array.isArray(nodeDownloads)) {
-                    if (window.exoDebugDownloads) console.log('[Download Debug] No downloads for node:', nodeId);
-                    continue;
-                }
-
-                if (window.exoDebugDownloads) {
-                    console.log('[Download Debug] Found downloads for node:', nodeId, nodeDownloads);
-                }
-
-                // Get the shard metadata for this runner to match against downloads
-                const shardWrapped = runnerToShard[runnerId];
-                if (!shardWrapped) continue;
-                
-                // Extract the shard metadata from the wrapped shard
-                const [_shardTag, shardMetadata] = getTagged(shardWrapped);
-                if (!shardMetadata) continue;
-
-                // Find matching download entry for this shard
-                for (const downloadWrapped of nodeDownloads) {
-                    const [downloadKind, downloadPayload] = getTagged(downloadWrapped);
-                    
-                    if (window.exoDebugDownloads) {
-                        console.log('[Download Debug] Processing download:', { downloadKind, downloadPayload });
-                    }
-                    
-                    // Check for any ongoing download
-                    if (downloadKind !== 'DownloadOngoing') {
-                        if (window.exoDebugDownloads) console.log('[Download Debug] Skipping non-ongoing download:', downloadKind);
-                        continue;
-                    }
-                    
-                    // Match by shard metadata - compare the actual shard metadata objects
-                    const downloadShardMetadata = pick(downloadPayload, 'shard_metadata', 'shardMetadata', null);
-                    if (!downloadShardMetadata) {
-                        if (window.exoDebugDownloads) console.log('[Download Debug] No shard metadata in download');
-                        continue;
-                    }
-                    
-                    // Extract the actual shard data from tagged union if needed
-                    let actualDownloadShard = downloadShardMetadata;
-                    if (typeof downloadShardMetadata === 'object') {
-                        const [_downloadShardTag, downloadShardData] = getTagged(downloadShardMetadata);
-                        if (downloadShardData) {
-                            actualDownloadShard = downloadShardData;
-                        }
-                    }
-                    
-                    // Get modelId from modelMeta (nested structure: shard.modelMeta.modelId)
-                    const downloadModelMeta = pick(actualDownloadShard, 'model_meta', 'modelMeta', null);
-                    const shardModelMeta = pick(shardMetadata, 'model_meta', 'modelMeta', null);
-                    const downloadModelId = downloadModelMeta ? pick(downloadModelMeta, 'model_id', 'modelId', null) : null;
-                    const shardModelId = shardModelMeta ? pick(shardModelMeta, 'model_id', 'modelId', null) : null;
-                    
-                    if (window.exoDebugDownloads) {
-                        console.log('[Download Debug] Comparing models:', { 
-                            downloadModelId, 
-                            shardModelId,
-                            downloadModelMeta,
-                            shardModelMeta
-                        });
-                    }
-                    
-                    if (downloadModelId && shardModelId && downloadModelId === shardModelId) {
-                        const rawProg = pick(downloadPayload, 'download_progress', 'downloadProgress', null);
-                        const normalized = normalizeProgress(rawProg);
-                        
-                        if (normalized) {
-                            if (window.exoDebugDownloads) {
-                                console.log('[Download Debug] Found matching download progress:', normalized);
-                            }
-                            details.push({ runnerId, nodeId, progress: normalized });
-                            totalBytes += normalized.totalBytes || 0;
-                            downloadedBytes += normalized.downloadedBytes || 0;
-                        }
-                        break;
-                    }
-                }
-            }
-
-            const isDownloadingAny = details.length > 0;
-            const progress = totalBytes > 0 ? ((downloadedBytes / totalBytes) * 100) : 0;
-            return { isDownloading: isDownloadingAny, progress, details };
-        }
-
-
-        // Helper function to unwrap tagged unions (defined globally for reuse)
-        function getTagged(obj) {
-            if (!obj || typeof obj !== 'object') return [null, null];
-            const keys = Object.keys(obj);
-            if (keys.length === 1 && typeof keys[0] === 'string') {
-                return [keys[0], obj[keys[0]]];
-            }
-            return [null, null];
-        }
-
-        // Derive a display status for an instance from its runners.
-        // Priority: FAILED > DOWNLOADING > LOADING > STARTING > RUNNING > READY > LOADED > WAITING > INACTIVE
-        function deriveInstanceStatus(instanceWrapped, runners = {}, downloads = {}) {
-            // Unwrap tagged Instance union
-            const [_instanceTag, instance] = getTagged(instanceWrapped);
-            if (!instance || typeof instance !== 'object') {
-                return { statusText: 'UNKNOWN', statusClass: 'inactive' };
-            }
-            
-            const runnerIds = Object.keys(instance.shardAssignments?.runnerToShard || {});
-
-            function canonicalStatusFromKind(kind) {
-                const map = {
-                    RunnerWaitingForModel: 'WaitingForModel',
-                    RunnerLoading: 'Loading',
-                    RunnerLoaded: 'Loaded',
-                    RunnerWarmingUp: 'WarmingUp',
-                    RunnerReady: 'Ready',
-                    RunnerRunning: 'Running',
-                    RunnerFailed: 'Failed',
-                };
-                return map[kind] || null;
-            }
-
-            const statuses = runnerIds
-                .map(rid => {
-                    const r = runners[rid];
-                    if (!r) return null;
-                    const [kind] = getTagged(r);
-                    if (kind) return canonicalStatusFromKind(kind);
-                    const s = r.runnerStatus;
-                    return (typeof s === 'string') ? s : null;
-                })
-                .filter(s => typeof s === 'string');
-
-            const has = (s) => statuses.includes(s);
-
-            if (statuses.length === 0) {
-                return { statusText: 'UNKNOWN', statusClass: 'inactive' };
-            }
-
-            if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
-            if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' };
-            if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' };
-            if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
-            if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' };
-            if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' };
-            if (has('WaitingForModel')) return { statusText: 'WAITING FOR MODEL', statusClass: 'starting' };
-
-            return { statusText: 'UNKNOWN', statusClass: 'inactive' };
-        }
-
-        function renderInstances(instances, runners = {}, downloads = {}) {
-            const instanceEntries = Object.entries(instances || {});
-            
-            if (instanceEntries.length === 0) {
-                instancesList.innerHTML = '<div class="no-instances">No instances running</div>';
-                return;
-            }
-
-            // Build maps for instance colors and connection usage
-            instanceIdToColor = {};
-            connectionToInstances = {};
-            
-            instanceEntries.forEach(([instanceId, instanceWrapped]) => {
-                // Validate instanceId
-                if (!instanceId || typeof instanceId !== 'string') {
-                    return;
-                }
-                
-                // Unwrap tagged Instance union
-                const [_instanceTag, instance] = getTagged(instanceWrapped);
-                if (!instance || typeof instance !== 'object') {
-                    return;
-                }
-                instanceIdToColor[instanceId] = generateInstanceColor(instanceId);
-                
-                // Determine which nodes this instance uses
-                const nodeToRunner = instance.shardAssignments?.nodeToRunner || {};
-                const nodesUsed = Object.keys(nodeToRunner);
-                
-                // For each pair of nodes, record that this instance uses that connection
-                for (let i = 0; i < nodesUsed.length; i++) {
-                    for (let j = i + 1; j < nodesUsed.length; j++) {
-                        const nodeA = nodesUsed[i];
-                        const nodeB = nodesUsed[j];
-                        const key = nodeA < nodeB ? `${nodeA}|${nodeB}` : `${nodeB}|${nodeA}`;
-                        
-                        if (!connectionToInstances[key]) {
-                            connectionToInstances[key] = [];
-                        }
-                        connectionToInstances[key].push(instanceId);
-                    }
-                }
-            });
-
-            const instancesHTML = instanceEntries.map(([instanceId, instanceWrapped]) => {
-                // Validate instanceId
-                if (!instanceId || typeof instanceId !== 'string') {
-                    return '';
-                }
-                
-                // Unwrap tagged Instance union
-                const [_instanceTag, instance] = getTagged(instanceWrapped);
-                if (!instance || typeof instance !== 'object') {
-                    return '';
-                }
-                
-                const modelId = instance.shardAssignments?.modelId || 'Unknown Model';
-                const truncatedInstanceId = instanceId.length > 8 
-                    ? instanceId.substring(0, 8) + '...' 
-                    : instanceId;
-                
-                // Create reverse mapping from runnerId to nodeId using nodeToRunner
-                const nodeToRunner = instance.shardAssignments?.nodeToRunner || {};
-                const runnerToNode = {};
-                Object.entries(nodeToRunner).forEach(([nodeId, runnerId]) => {
-                    runnerToNode[runnerId] = nodeId;
-                });
-                
-                // Extract sharding strategy from the first shard
-                // Shards are tagged unions: {"PipelineShardMetadata": {...}} or {"TensorShardMetadata": {...}}
-                const runnerToShard = instance.shardAssignments?.runnerToShard || {};
-                const firstShardWrapped = Object.values(runnerToShard)[0];
-                let shardingType = 'Unknown';
-                if (firstShardWrapped) {
-                    const [shardTag, _shardData] = getTagged(firstShardWrapped);
-                    if (shardTag === 'PipelineShardMetadata') {
-                        shardingType = 'Pipeline';
-                    } else if (shardTag === 'TensorShardMetadata') {
-                        shardingType = 'Tensor';
-                    }
-                }
-                
-                // Extract instance type from the tagged union
-                // Instance is tagged as {"MlxRingInstance": {...}} or {"MlxIbvInstance": {...}}
-                let instanceType = 'Unknown';
-                if (_instanceTag === 'MlxRingInstance') {
-                    instanceType = 'MLX Ring';
-                } else if (_instanceTag === 'MlxIbvInstance') {
-                    instanceType = 'MLX IBV';
-                }
-                
-                const parallelizationStrategy = `${shardingType} (${instanceType})`;
-                
-                // Generate hosts HTML using runner IDs and friendly names
-                const runnerIds = Object.keys(runnerToShard);
-                const hostsHTML = runnerIds.map(runnerId => {
-                    const nodeId = runnerToNode[runnerId];
-                    const friendlyName = nodeId && nodeIdToFriendlyName[nodeId] 
-                        ? nodeIdToFriendlyName[nodeId] 
-                        : 'Unknown Node';
-                    const shortId = runnerId.slice(-4);
-                    return `<span class="instance-host">${friendlyName} (${shortId})</span>`;
-                }).join('') || '';
-
-                // Calculate download status for this instance (pass wrapped instance)
-                const downloadStatus = calculateInstanceDownloadStatus(instanceWrapped, runners, downloads);
-
-                let statusText, statusClass;
-                if (downloadStatus.isDownloading) {
-                    ({ statusText, statusClass } = { statusText: 'DOWNLOADING', statusClass: 'downloading' });
-                } else {
-                    ({ statusText, statusClass } = deriveInstanceStatus(instanceWrapped, runners, downloads));
-                }
-
-                // Generate download progress HTML - overall + per node with file details
-                let downloadProgressHTML = '';
-                if (downloadStatus.isDownloading) {
-                    // Calculate overall progress across all nodes
-                    const overallPct = (downloadStatus.progress || 0).toFixed(2);
-                    const totalBytesAll = downloadStatus.details.reduce((sum, d) => sum + (d.progress.totalBytes || 0), 0);
-                    const downloadedBytesAll = downloadStatus.details.reduce((sum, d) => sum + (d.progress.downloadedBytes || 0), 0);
-                    const nodeCount = downloadStatus.details.length;
-                    
-                    // Overall progress section
-                    const overallHTML = `
-                        <div class="overall-download-summary">
-                            <div class="overall-download-header">
-                                <span class="overall-download-label">Overall</span>
-                                <span class="overall-download-percent">${overallPct}%</span>
-                            </div>
-                            <div class="progress-bar-container">
-                                <div class="progress-bar" style="width: ${overallPct}%;"></div>
-                            </div>
-                            <div class="overall-download-stats">${formatBytes(downloadedBytesAll)} / ${formatBytes(totalBytesAll)} • ${nodeCount} runner${nodeCount !== 1 ? 's' : ''}</div>
-                        </div>
-                    `;
-                    
-                    const perNodeHTML = (downloadStatus.details || []).map(({ runnerId, nodeId, progress }) => {
-                        const nodeName = (nodeId && nodeIdToFriendlyName[nodeId]) 
-                            ? nodeIdToFriendlyName[nodeId] 
-                            : (nodeIdToFriendlyName[runnerId] || 'Unknown Node');
-                        const pctText = (progress.percentage || 0).toFixed(2);
-                        const etaStr = formatDurationMs(progress.etaMs);
-                        const bytesStr = `${formatBytes(progress.downloadedBytes)} / ${formatBytes(progress.totalBytes)}`;
-                        const speedStr = formatBytesPerSecond(progress.speed);
-                        const filesSummary = `${progress.completedFiles}/${progress.totalFiles} files`;
-                        
-                        // Separate files into in-progress and completed
-                        const allFiles = progress.files || [];
-                        const inProgressFiles = allFiles.filter(f => (f.percentage || 0) < 100);
-                        const completedFiles = allFiles.filter(f => (f.percentage || 0) >= 100);
-
-                        // Generate HTML for in-progress files
-                        const inProgressHTML = inProgressFiles.map(f => {
-                            const fPct = f.percentage || 0;
-                            const fBytes = `${formatBytes(f.downloadedBytes)} / ${formatBytes(f.totalBytes)}`;
-                            const fEta = formatDurationMs(f.etaMs);
-                            const fSpeed = formatBytesPerSecond(f.speed);
-                            const pctFormatted = formatPercent(fPct, 2);
-                            return `
-                                <div class="download-file">
-                                    <div class="download-file-header">
-                                        <span class="download-file-name" title="${f.name}">${f.name}</span>
-                                        <span class="download-file-percent">${pctFormatted}</span>
-                                    </div>
-                                    <div class="download-file-subtext">${fBytes} • ETA ${fEta} • ${fSpeed}</div>
-                                    <div class="progress-bar-container"><div class="progress-bar" style="width: ${Math.max(0, Math.min(100, fPct)).toFixed(2)}%;"></div></div>
-                                </div>
-                            `;
-                        }).join('');
-
-                        // Generate HTML for completed files
-                        const completedHTML = completedFiles.length > 0 ? `
-                            <div class="completed-files-section">
-                                <div class="completed-files-header">Completed (${completedFiles.length})</div>
-                                <div class="completed-files-list">
-                                    ${completedFiles.map(f => `<div class="completed-file-item" title="${f.name}">${f.name}</div>`).join('')}
-                                </div>
-                            </div>
-                        ` : '';
-                        
-                        return `
-                            <div class="node-download-summary">
-                                <div class="node-download-header">
-                                    <span class="node-download-name">${nodeName}</span>
-                                    <span class="node-download-percent">${pctText}%</span>
-                                </div>
-                                <div class="progress-bar-container">
-                                    <div class="progress-bar" style="width: ${pctText}%;"></div>
-                                </div>
-                                <div class="node-download-stats">${etaStr} · ${bytesStr} · ${speedStr} · ${filesSummary}</div>
-                                <div class="download-files-list">
-                                    ${inProgressHTML}
-                                </div>
-                                ${completedHTML}
-                            </div>
-                        `;
-                    }).join('');
-
-                    downloadProgressHTML = overallHTML + perNodeHTML;
-                }
-
-                const shardCount = Object.keys(runnerToShard).length;
-                
-                // Use the instance's color for the indicator
-                const instanceColor = instanceIdToColor[instanceId] || 'var(--exo-yellow)';
-                const borderStyle = `background-color: ${instanceColor};`;
-                
-                return `
-                    <div class="instance-item">
-                        <div class="instance-color-indicator" style="${borderStyle}"></div>
-                        <div class="instance-header">
-                            <div class="instance-info">
-                                <span class="instance-id">${truncatedInstanceId}</span>
-                                <span class="instance-status ${statusClass}">${statusText}</span>
-                            </div>
-                            <div class="instance-actions">
-                                <button class="instance-delete-button" data-instance-id="${instanceId}" title="Delete Instance">
-                                    Delete
-                                </button>
-                            </div>
-                        </div>
-                        <div class="instance-model">${modelId} <span style="color: var(--exo-light-gray); opacity: 0.8;">(${shardCount} runner${shardCount !== 1 ? 's' : ''})</span></div>
-                        <div class="instance-strategy">Strategy: <span class="instance-strategy-value">${parallelizationStrategy}</span></div>
-                        
-                        ${downloadProgressHTML}
-                        ${hostsHTML ? `<div class="instance-hosts">${hostsHTML}</div>` : ''}
-                    </div>
-                `;
-            }).join('');
-
-            instancesList.innerHTML = instancesHTML;
-            
-            // Add event listeners to delete buttons using event delegation
-            instancesList.removeEventListener('click', handleInstanceListClick);
-            instancesList.addEventListener('click', handleInstanceListClick);
-        }
-
-        // Handle clicks on the instances list (event delegation)
-        function handleInstanceListClick(event) {
-            if (event.target.classList.contains('instance-delete-button')) {
-                const instanceId = event.target.getAttribute('data-instance-id');
-                if (instanceId) {
-                    deleteInstance(instanceId);
-                }
-            }
-        }
-
-        // Delete instance with confirmation
-        async function deleteInstance(instanceId) {
-            const confirmMessage = `Are you sure you want to delete instance ${instanceId}?\n\nThis action cannot be undone.`;
-            
-            if (!confirm(confirmMessage)) {
-                return;
-            }
-
-            try {
-                const response = await fetch(`${window.location.origin}/instance/${instanceId}`, {
-                    method: 'DELETE',
-                    headers: {
-                        'Content-Type': 'application/json',
-                    },
-                });
-
-                if (!response.ok) {
-                    const errorData = await response.json().catch(() => ({}));
-                    throw new Error(errorData.detail || `Failed to delete instance: ${response.status}`);
-                }
-
-                const result = await response.json();
-                console.log('Instance deletion initiated:', result);
-                
-                // Refresh instances list immediately to show the change
-                fetchAndRenderInstances();
-                
-            } catch (error) {
-                console.error('Error deleting instance:', error);
-                alert(`Error deleting instance: ${error.message}`);
-            }
-        }
-
-        // Helper function to create edge labels with optional colored indicators for instances
-        function createEdgeLabel(labelLines, labelX, labelY, parentGroup, instanceColors = []) {
-            if (!labelLines || labelLines.length === 0) return;
-            
-            const colorStripWidth = 3;  // Narrow strip width
-            const colorStripHeight = 12; // Taller for visibility
-            const colorStripSpacing = 1.5; // Small gap between strips
-            const paddingBetweenStripsAndText = 8; // Space between strips and text
-            const hasColorBoxes = instanceColors.length > 0;
-            
-            // Create color indicator strips if colors are provided
-            let totalColorBoxWidth = 0;
-            if (hasColorBoxes) {
-                totalColorBoxWidth = instanceColors.length * (colorStripWidth + colorStripSpacing) - colorStripSpacing;
-                const stripsStartX = labelX - totalColorBoxWidth - paddingBetweenStripsAndText - 30; // Move 30px further left
-                
-                instanceColors.forEach((color, idx) => {
-                    const colorStrip = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    // Position strips well to the left of the text
-                    const stripX = stripsStartX + idx * (colorStripWidth + colorStripSpacing);
-                    colorStrip.setAttribute('x', stripX);
-                    colorStrip.setAttribute('y', labelY - colorStripHeight / 2);
-                    colorStrip.setAttribute('width', colorStripWidth);
-                    colorStrip.setAttribute('height', colorStripHeight);
-                    colorStrip.setAttribute('fill', color);
-                    colorStrip.setAttribute('stroke', 'var(--exo-light-gray)');
-                    colorStrip.setAttribute('stroke-width', '0.5');
-                    colorStrip.setAttribute('rx', 1);
-                    parentGroup.appendChild(colorStrip);
-                });
-            }
-            
-            // Create text element
-            const labelText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-            labelText.setAttribute('class', 'edge-label');
-            labelText.setAttribute('x', labelX);
-            labelText.setAttribute('y', labelY);
-            
-            // Add background for better readability
-            const labelBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-            labelBg.setAttribute('class', 'edge-label-bg');
-            
-            // Add each line as a tspan
-            labelLines.forEach((line, idx) => {
-                const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
-                tspan.setAttribute('x', labelX);
-                tspan.setAttribute('dy', idx === 0 ? '0' : '1.1em');
-                tspan.textContent = line;
-                labelText.appendChild(tspan);
-            });
-            
-            // Add text first to get bounding box, then add background
-            parentGroup.appendChild(labelText);
-            
-            // Get text bounding box and create background rect
-            try {
-                const bbox = labelText.getBBox();
-                const padding = 3;
-                const extraLeft = hasColorBoxes ? totalColorBoxWidth : 0;
-                
-                // Background should cover text area only, strips are separate
-                labelBg.setAttribute('x', bbox.x - padding);
-                labelBg.setAttribute('y', bbox.y - padding);
-                labelBg.setAttribute('width', bbox.width + 2 * padding);
-                labelBg.setAttribute('height', bbox.height + 2 * padding);
-                labelBg.setAttribute('rx', 2);
-                parentGroup.insertBefore(labelBg, labelText);
-            } catch (e) {
-                console.error('Failed to get bbox for label:', e);
-            }
-        }
-
-        function renderNodes(topologyData) {
-            if (!topologyGraphContainer) return;
-            topologyGraphContainer.innerHTML = ''; // Clear previous SVG content
-
-            const nodesData = (topologyData && topologyData.nodes) ? topologyData.nodes : {};
-            const edgesData = (topologyData && Array.isArray(topologyData.edges)) ? topologyData.edges : [];
-            const nodeIds = Object.keys(nodesData);
-
-            // Update min nodes radio buttons based on current topology
-            currentNodeCount = Math.max(1, nodeIds.length);
-            if (minNodesOptions) {
-                // Get currently selected value before regenerating
-                const currentlySelected = document.querySelector('input[name="min_nodes"]:checked');
-                const hasOnlyDefaultOption = minNodesOptions.children.length === 1;
-                // Default to maximum nodes on initial load, otherwise preserve user selection
-                const selectedValue = (currentlySelected && !hasOnlyDefaultOption) ? parseInt(currentlySelected.value, 10) : currentNodeCount;
-                
-                // Clear and regenerate radio buttons
-                minNodesOptions.innerHTML = '';
-                for (let i = 1; i <= currentNodeCount; i++) {
-                    const optionDiv = document.createElement('div');
-                    optionDiv.className = 'strategy-option';
-                    
-                    const radio = document.createElement('input');
-                    radio.type = 'radio';
-                    radio.id = `minNodes${i}`;
-                    radio.name = 'min_nodes';
-                    radio.value = i.toString();
-                    // Check if this should be selected (preserve selection or default to maximum)
-                    radio.checked = (i === Math.min(selectedValue, currentNodeCount));
-                    
-                    const label = document.createElement('label');
-                    label.htmlFor = `minNodes${i}`;
-                    label.textContent = i.toString();
-                    
-                    optionDiv.appendChild(radio);
-                    optionDiv.appendChild(label);
-                    minNodesOptions.appendChild(optionDiv);
-                }
-            }
-
-            if (nodeIds.length === 0) {
-                const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-                textEl.setAttribute('x', '50%');
-                textEl.setAttribute('y', '50%');
-                textEl.setAttribute('alignment-baseline', 'middle');
-                textEl.setAttribute('text-anchor', 'middle');
-                textEl.setAttribute('fill', 'var(--exo-light-gray)');
-                textEl.setAttribute('font-size', '16');
-                textEl.textContent = 'No nodes discovered yet.';
-                topologyGraphContainer.appendChild(textEl);
-                return;
-            }
-
-            const svgRect = topologyGraphContainer.getBoundingClientRect();
-            const width = svgRect.width;
-            const height = svgRect.height;
-            const centerX = width / 2;
-            const centerY = height / 2;
-            const numNodes = nodeIds.length;
-            const radius = Math.min(width, height) * 0.28; // Radius of the circle for node placement
-            const nodeRadius = 72; // Base size for overall node element, influences icon/bar sizes
-
-            const nodesWithPositions = nodeIds.map((id, index) => {
-                const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2); // Start from top
-                return {
-                    id: id,
-                    data: nodesData[id],
-                    x: centerX + radius * Math.cos(angle),
-                    y: centerY + radius * Math.sin(angle)
-                };
-            });
-
-            // Add arrowhead definition (supports bidirectional arrows on a single line)
-            const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
-            const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
-            marker.setAttribute('id', 'arrowhead');
-            marker.setAttribute('viewBox', '0 0 10 10');
-            marker.setAttribute('refX', '10');
-            marker.setAttribute('refY', '5');
-            marker.setAttribute('markerWidth', '11');
-            marker.setAttribute('markerHeight', '11');
-            marker.setAttribute('orient', 'auto-start-reverse');
-            // Draw a subtle V-tip (no filled body)
-            const markerTip = document.createElementNS('http://www.w3.org/2000/svg', 'path');
-            markerTip.setAttribute('d', 'M 0 0 L 10 5 L 0 10');
-            markerTip.setAttribute('fill', 'none');
-            markerTip.setAttribute('stroke', 'var(--exo-light-gray)');
-            markerTip.setAttribute('stroke-width', '1.6');
-            markerTip.setAttribute('stroke-linecap', 'round');
-            markerTip.setAttribute('stroke-linejoin', 'round');
-            markerTip.setAttribute('stroke-dasharray', 'none');
-            markerTip.setAttribute('stroke-dashoffset', '0');
-            markerTip.setAttribute('style', 'animation: none; pointer-events: none;');
-            marker.appendChild(markerTip);
-            defs.appendChild(marker);
-            topologyGraphContainer.appendChild(defs);
-
-            // Create groups for links and separate arrow markers (so arrows are not affected by line animations)
-            const linksGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-            linksGroup.setAttribute('class', 'links-group');
-            linksGroup.setAttribute('style', 'pointer-events: none;');
-            const arrowsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-            arrowsGroup.setAttribute('class', 'arrows-group');
-            arrowsGroup.setAttribute('style', 'pointer-events: none;');
-            const edgeLabelsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-            edgeLabelsGroup.setAttribute('class', 'edge-labels-group');
-            edgeLabelsGroup.setAttribute('style', 'pointer-events: none;');
-
-            // Build quick lookup for node positions
-            const positionById = {};
-            nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
-
-            // Group directed edges into undirected pairs to support single line with two arrows
-            const pairMap = new Map(); // key: "a|b" with a<b, value: { a, b, aToB, bToA, aToBEdges, bToAEdges }
-            edgesData.forEach(edge => {
-                if (!edge || !edge.source || !edge.target) return;
-                if (!positionById[edge.source] || !positionById[edge.target]) return;
-                if (edge.source === edge.target) return;
-                const a = edge.source < edge.target ? edge.source : edge.target;
-                const b = edge.source < edge.target ? edge.target : edge.source;
-                const key = `${a}|${b}`;
-                const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false, aToBEdges: [], bToAEdges: [] };
-                if (edge.source === a && edge.target === b) {
-                    entry.aToB = true;
-                    entry.aToBEdges.push(edge); // Store all A->B edges
-                } else {
-                    entry.bToA = true;
-                    entry.bToAEdges.push(edge); // Store all B->A edges
-                }
-                pairMap.set(key, entry);
-            });
-
-            // Draw one line per undirected pair with separate arrow carrier lines
-            pairMap.forEach(entry => {
-                const posA = positionById[entry.a];
-                const posB = positionById[entry.b];
-                if (!posA || !posB) return;
-
-                // Full-length center-to-center lines
-                const x1 = posA.x;
-                const y1 = posA.y;
-                const x2 = posB.x;
-                const y2 = posB.y;
-
-                // Base animated dashed line (no markers)
-                const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
-                baseLine.setAttribute('x1', x1);
-                baseLine.setAttribute('y1', y1);
-                baseLine.setAttribute('x2', x2);
-                baseLine.setAttribute('y2', y2);
-                baseLine.setAttribute('class', 'graph-link');
-                linksGroup.appendChild(baseLine);
-
-                // Arrowheads centered on the line (tip lies exactly on the line),
-                // offset along the tangent so opposite directions straddle the center.
-                const dx = x2 - x1;
-                const dy = y2 - y1;
-                const len = Math.hypot(dx, dy) || 1;
-                const ux = dx / len;
-                const uy = dy / len;
-                const mx = (x1 + x2) / 2;
-                const my = (y1 + y2) / 2;
-                const tipOffset = 16; // shift arrow tips away from the exact center along the line
-                const carrier = 2; // short carrier segment length to define orientation
-
-                if (entry.aToB) {
-                    // Arrow pointing A -> B: place tip slightly before center along +tangent
-                    const tipX = mx - ux * tipOffset;
-                    const tipY = my - uy * tipOffset;
-                    const sx = tipX - ux * carrier;
-                    const sy = tipY - uy * carrier;
-                    const ex = tipX;
-                    const ey = tipY;
-                    const arrowSeg = document.createElementNS('http://www.w3.org/2000/svg', 'line');
-                    arrowSeg.setAttribute('x1', sx);
-                    arrowSeg.setAttribute('y1', sy);
-                    arrowSeg.setAttribute('x2', ex);
-                    arrowSeg.setAttribute('y2', ey);
-                    arrowSeg.setAttribute('stroke', 'none');
-                    arrowSeg.setAttribute('fill', 'none');
-                    arrowSeg.setAttribute('marker-end', 'url(#arrowhead)');
-                    arrowsGroup.appendChild(arrowSeg);
-                    
-                    // Add label for A->B direction (show all connections)
-                    if (window.exoShowEdgeIPs && entry.aToBEdges && entry.aToBEdges.length > 0) {
-                        // Count occurrences of each IP/interface combination
-                        const connectionCounts = new Map();
-                        
-                        entry.aToBEdges.forEach(edgeData => {
-                            if (edgeData.sendBackIp) {
-                                let ipLabel = edgeData.sendBackIp;
-                                if (edgeData.sendBackInterface) {
-                                    ipLabel = `${edgeData.sendBackInterface}: ${ipLabel}`;
-                                }
-                                connectionCounts.set(ipLabel, (connectionCounts.get(ipLabel) || 0) + 1);
-                            }
-                        });
-                        
-                        // Build label lines with counts for duplicates
-                        const labelLines = [];
-                        connectionCounts.forEach((count, ipLabel) => {
-                            if (count > 1) {
-                                labelLines.push(`${ipLabel} (${count})`);
-                            } else {
-                                labelLines.push(ipLabel);
-                            }
-                        });
-                        
-                        if (labelLines.length > 0) {
-                            // Position label before the A->B arrow (toward A side, away from arrow tip)
-                            // Move further back from center along the line toward A
-                            const labelPosX = mx - ux * (tipOffset * 2.5);
-                            const labelPosY = my - uy * (tipOffset * 2.5);
-                            // Offset perpendicular to the line (to the side)
-                            const perpX = -uy;
-                            const perpY = ux;
-                            const labelOffset = 25; // Increased offset to be clearly beside the line
-                            const labelX = labelPosX + perpX * labelOffset;
-                            const labelY = labelPosY + perpY * labelOffset;
-                            
-                            // Get colors for instances using this connection
-                            const connectionKey = `${entry.a}|${entry.b}`;
-                            const instancesUsingConnection = connectionToInstances[connectionKey] || [];
-                            const instanceColors = instancesUsingConnection.map(id => instanceIdToColor[id]).filter(c => c);
-                            
-                            createEdgeLabel(labelLines, labelX, labelY, edgeLabelsGroup, instanceColors);
-                        }
-                    }
-                }
-
-                if (entry.bToA) {
-                    // Arrow pointing B -> A: place tip slightly after center along -tangent
-                    const tipX = mx + ux * tipOffset;
-                    const tipY = my + uy * tipOffset;
-                    const sx = tipX + ux * carrier; // start ahead so the segment points toward tip
-                    const sy = tipY + uy * carrier;
-                    const ex = tipX;
-                    const ey = tipY;
-                    const arrowSeg = document.createElementNS('http://www.w3.org/2000/svg', 'line');
-                    arrowSeg.setAttribute('x1', sx);
-                    arrowSeg.setAttribute('y1', sy);
-                    arrowSeg.setAttribute('x2', ex);
-                    arrowSeg.setAttribute('y2', ey);
-                    arrowSeg.setAttribute('stroke', 'none');
-                    arrowSeg.setAttribute('fill', 'none');
-                    arrowSeg.setAttribute('marker-end', 'url(#arrowhead)');
-                    arrowsGroup.appendChild(arrowSeg);
-                    
-                    // Add label for B->A direction (show all connections)
-                    if (window.exoShowEdgeIPs && entry.bToAEdges && entry.bToAEdges.length > 0) {
-                        // Count occurrences of each IP/interface combination
-                        const connectionCounts = new Map();
-                        
-                        entry.bToAEdges.forEach(edgeData => {
-                            if (edgeData.sendBackIp) {
-                                let ipLabel = edgeData.sendBackIp;
-                                if (edgeData.sendBackInterface) {
-                                    ipLabel = `${edgeData.sendBackInterface}: ${ipLabel}`;
-                                }
-                                connectionCounts.set(ipLabel, (connectionCounts.get(ipLabel) || 0) + 1);
-                            }
-                        });
-                        
-                        // Build label lines with counts for duplicates
-                        const labelLines = [];
-                        connectionCounts.forEach((count, ipLabel) => {
-                            if (count > 1) {
-                                labelLines.push(`${ipLabel} (${count})`);
-                            } else {
-                                labelLines.push(ipLabel);
-                            }
-                        });
-                        
-                        if (labelLines.length > 0) {
-                            // Position label before the B->A arrow (toward B side, away from arrow tip)
-                            // Move further back from center along the line toward B
-                            const labelPosX = mx + ux * (tipOffset * 2.5);
-                            const labelPosY = my + uy * (tipOffset * 2.5);
-                            // Offset perpendicular to the line (to the side)
-                            const perpX = -uy;
-                            const perpY = ux;
-                            const labelOffset = 25; // Increased offset to be clearly beside the line
-                            const labelX = labelPosX + perpX * labelOffset;
-                            const labelY = labelPosY + perpY * labelOffset;
-                            
-                            // Get colors for instances using this connection
-                            const connectionKey = `${entry.a}|${entry.b}`;
-                            const instancesUsingConnection = connectionToInstances[connectionKey] || [];
-                            const instanceColors = instancesUsingConnection.map(id => instanceIdToColor[id]).filter(c => c);
-                            
-                            createEdgeLabel(labelLines, labelX, labelY, edgeLabelsGroup, instanceColors);
-                        }
-                    }
-                }
-            });
-            // Create group for nodes
-            const nodesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-            nodesGroup.setAttribute('class', 'nodes-group');
-
-            nodesWithPositions.forEach(nodeInfo => {
-                const node = nodeInfo.data;
-                const nodeId = nodeInfo.id;
-
-                // --- Calculate additional metrics for display ---
-                let ramUsagePercent = 0;
-                let gpuTempDisplay = 'N/A';
-                let ramUsedFormatted = '0 Bytes';
-                let ramTotalFormatted = 'N/A';
-                let gpuUsagePercent = 0;
-                let gpuTempForColor = NaN;
-                let sysPower = 'N/A';
-
-                const macmon = node.macmon_info;
-                if (macmon) {
-                    if (macmon.memory && macmon.memory.ram_total > 0) {
-                        ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
-                        ramUsedFormatted = formatBytes(macmon.memory.ram_usage, 1);
-                        ramTotalFormatted = formatBytes(macmon.memory.ram_total, 1);
-                    }
-                    if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
-                        let originalGpuTemp = macmon.temp.gpu_temp_avg;
-                        let clampedGpuTemp = Math.max(30, originalGpuTemp);
-                        gpuTempDisplay = `${clampedGpuTemp.toFixed(0)}°C`;
-                        gpuTempForColor = clampedGpuTemp;
-                    } else {
-                        gpuTempDisplay = 'N/A';
-                        gpuTempForColor = NaN;
-                    }
-                    if (macmon.gpu_usage && typeof macmon.gpu_usage[1] === 'number') { // Calculate GPU Usage Percentage
-                        gpuUsagePercent = macmon.gpu_usage[1] * 100;
-                    }
-                    if (macmon.sys_power) sysPower = `${macmon.sys_power.toFixed(1)} W`;
-                } else { // If no macmon_info, indicate data is missing for these
-                    ramUsagePercent = 0;
-                    gpuTempDisplay = 'N/A';
-                    ramUsedFormatted = 'N/A';
-                    ramTotalFormatted = 'N/A';
-                    gpuUsagePercent = 0;
-                    sysPower = 'N/A'; // Ensure sysPower is handled
-                }
-                // --- End Calculate additional metrics ---
-
-                let fillCol = 'var(--exo-medium-gray)'; // Default status color for the circle
-                const lastSeenSeconds = (Date.now() / 1000) - node.last_macmon_update;
-                if (lastSeenSeconds > 60 * 60) { // Over 1 hour
-                    fillCol = '#F44336'; // Error - red
-                } else if (lastSeenSeconds > 60 * 5) { // Over 5 minutes
-                    fillCol = 'var(--exo-yellow)'; // Warning - yellow
-                } else if (!node.macmon_info) {
-                    fillCol = 'var(--exo-yellow)'; // Warning if no macmon_info but seen recently
-                }
-
-
-                const nodeG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-                nodeG.setAttribute('class', 'graph-node');
-
-                const modelId = node.system_info ? node.system_info.model_id : 'Unknown';
-
-                // --- Icon Dimensions & Config ---
-                let iconBaseWidth = nodeRadius * 1.2;
-                let iconBaseHeight = nodeRadius * 1.0;
-                let specificIconGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
-                const clipPathId = `clipPath-${nodeId.replace(/[^a-zA-Z0-9]/g, '-')}`;
-
-                // Add tooltip for node ID (remains on the group)
-                const titleEl = document.createElementNS('http://www.w3.org/2000/svg', 'title');
-                titleEl.textContent = `ID: ${nodeId}\nLast Seen: ${timeSince(node.last_macmon_update)}`;
-                nodeG.appendChild(titleEl);
-
-                // --- Device Specific Icon Drawing ---
-                if (modelId.toLowerCase() === "mac studio") {
-                    iconBaseWidth = nodeRadius * 1.25; // Slightly wider based on typical Studio proportions
-                    iconBaseHeight = nodeRadius * 0.85; // And a bit flatter than a perfect cube
-                    const x = nodeInfo.x - iconBaseWidth / 2;
-                    const y = nodeInfo.y - iconBaseHeight / 2;
-                    const cornerRadius = 4;
-                    const topSurfaceHeight = iconBaseHeight * 0.15; // Height of the top bevel/surface
-
-                    // Define ClipPath for the main front body (where memory fill goes)
-                    let defs = nodeG.querySelector('defs');
-                    if (!defs) {
-                        defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
-                        nodeG.appendChild(defs);
-                    }
-                    const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
-                    clipPath.setAttribute('id', clipPathId);
-                    const clipRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    // Clip path matches the main front body, excluding the top surface part shown visually
-                    clipRect.setAttribute('x', x);
-                    clipRect.setAttribute('y', y + topSurfaceHeight);
-                    clipRect.setAttribute('width', iconBaseWidth);
-                    clipRect.setAttribute('height', iconBaseHeight - topSurfaceHeight);
-                    clipRect.setAttribute('rx', cornerRadius -1 > 0 ? cornerRadius -1 : 0); // Slightly less rounding for internal clip if needed
-                    clipPath.appendChild(clipRect);
-                    defs.appendChild(clipPath);
-
-                    // Top Surface (like the image)
-                    const topRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    topRect.setAttribute('x', x);
-                    topRect.setAttribute('y', y);
-                    topRect.setAttribute('width', iconBaseWidth);
-                    topRect.setAttribute('height', iconBaseHeight); // Full height, front will overlay
-                    topRect.setAttribute('rx', cornerRadius);
-                    topRect.setAttribute('ry', cornerRadius);
-                    topRect.style.fill = 'var(--exo-medium-gray)'; // Color of the top surface
-                    topRect.setAttribute('stroke', 'var(--exo-light-gray)');
-                    topRect.setAttribute('stroke-width', '1px');
-                    specificIconGroup.appendChild(topRect);
-
-                    // Main Front Body (this is where memory fill will be visible)
-                    const frontBody = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    frontBody.setAttribute('x', x);
-                    frontBody.setAttribute('y', y + topSurfaceHeight);
-                    frontBody.setAttribute('width', iconBaseWidth);
-                    frontBody.setAttribute('height', iconBaseHeight - topSurfaceHeight);
-                    frontBody.style.fill = fillCol; // Node status color
-                    // No stroke for frontBody, as topRect provides the outline appearance
-                    specificIconGroup.appendChild(frontBody);
-
-                    // Memory Fill (clipped to frontBody area)
-                    if (ramUsagePercent > 0) {
-                        const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
-                        const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-                        const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                        memoryFillRect.setAttribute('x', x);
-                        memoryFillRect.setAttribute('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight)); // From bottom up
-                        memoryFillRect.setAttribute('width', iconBaseWidth);
-                        memoryFillRect.setAttribute('height', memFillActualHeight);
-                        let memFillColor = 'var(--exo-yellow)';
-                        if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
-                        memoryFillRect.style.fill = memFillColor;
-                        memoryFillRect.style.opacity = '0.75';
-                        memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
-                        specificIconGroup.appendChild(memoryFillRect);
-                    }
-
-                    // Front Panel Details (slots, LED)
-                    const detailColor = 'rgba(0,0,0,0.3)'; // Darker color for cutouts
-                    const slotHeight = iconBaseHeight * 0.12;
-                    const slotCornerRadius = 1.5;
-
-                    // Vertical slots (2)
-                    const vSlotWidth = iconBaseWidth * 0.05;
-                    const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.65 - slotHeight / 2;
-                    const vSlot1X = x + iconBaseWidth * 0.15;
-                    const vSlot2X = x + iconBaseWidth * 0.25;
-
-                    [vSlot1X, vSlot2X].forEach(vx => {
-                        const vSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                        vSlot.setAttribute('x', vx - vSlotWidth / 2);
-                        vSlot.setAttribute('y', vSlotY);
-                        vSlot.setAttribute('width', vSlotWidth);
-                        vSlot.setAttribute('height', slotHeight);
-                        vSlot.setAttribute('fill', detailColor);
-                        vSlot.setAttribute('rx', slotCornerRadius);
-                        specificIconGroup.appendChild(vSlot);
-                    });
-
-                    // Horizontal slot for Mac Studio - RESTORED
-                    const hSlotWidth = iconBaseWidth * 0.2;
-                    const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
-                    const hSlotY = vSlotY; // Align with vertical slots for simplicity
-                    const hSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    hSlot.setAttribute('x', hSlotX);
-                    hSlot.setAttribute('y', hSlotY);
-                    hSlot.setAttribute('width', hSlotWidth);
-                    hSlot.setAttribute('height', slotHeight * 0.6); // Thinner horizontal slot
-                    hSlot.setAttribute('fill', detailColor);
-                    hSlot.setAttribute('rx', slotCornerRadius * 0.7);
-                    specificIconGroup.appendChild(hSlot);
-
-                    // LED indicator
-                    const ledRadius = iconBaseWidth * 0.025;
-                    const ledX = x + iconBaseWidth * 0.85;
-                    const ledY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.7;
-                    const led = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
-                    led.setAttribute('cx', ledX);
-                    led.setAttribute('cy', ledY);
-                    led.setAttribute('r', ledRadius);
-                    led.setAttribute('fill', 'var(--exo-light-gray)'); // Subtle LED color
-                    specificIconGroup.appendChild(led);
-
-                } else if (modelId.toLowerCase() === "mac mini") {
-                    iconBaseWidth = nodeRadius * 1.3;  // Mini is wide
-                    iconBaseHeight = nodeRadius * 0.7; // and quite flat
-                    const x = nodeInfo.x - iconBaseWidth / 2;
-                    const y = nodeInfo.y - iconBaseHeight / 2;
-                    const cornerRadius = 3;
-                    const topSurfaceHeight = iconBaseHeight * 0.20; // Proportionally similar top surface
-
-                    // Define ClipPath for the main front body
-                    let defs = nodeG.querySelector('defs');
-                    if (!defs) {
-                        defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
-                        nodeG.appendChild(defs);
-                    }
-                    const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
-                    clipPath.setAttribute('id', clipPathId); // Use the same clipPathId logic
-                    const clipRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    clipRect.setAttribute('x', x);
-                    clipRect.setAttribute('y', y + topSurfaceHeight);
-                    clipRect.setAttribute('width', iconBaseWidth);
-                    clipRect.setAttribute('height', iconBaseHeight - topSurfaceHeight);
-                    clipRect.setAttribute('rx', cornerRadius -1 > 0 ? cornerRadius -1 : 0);
-                    clipPath.appendChild(clipRect);
-                    defs.appendChild(clipPath);
-
-                    // Top Surface
-                    const topRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    topRect.setAttribute('x', x);
-                    topRect.setAttribute('y', y);
-                    topRect.setAttribute('width', iconBaseWidth);
-                    topRect.setAttribute('height', iconBaseHeight);
-                    topRect.setAttribute('rx', cornerRadius);
-                    topRect.setAttribute('ry', cornerRadius);
-                    topRect.style.fill = 'var(--exo-medium-gray)';
-                    topRect.setAttribute('stroke', 'var(--exo-light-gray)');
-                    topRect.setAttribute('stroke-width', '1px');
-                    specificIconGroup.appendChild(topRect);
-
-                    // Main Front Body (for memory fill)
-                    const frontBody = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    frontBody.setAttribute('x', x);
-                    frontBody.setAttribute('y', y + topSurfaceHeight);
-                    frontBody.setAttribute('width', iconBaseWidth);
-                    frontBody.setAttribute('height', iconBaseHeight - topSurfaceHeight);
-                    frontBody.style.fill = fillCol; // Node status color
-                    specificIconGroup.appendChild(frontBody);
-
-                    // Memory Fill (clipped to frontBody area)
-                    if (ramUsagePercent > 0) {
-                        const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
-                        const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-                        const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                        memoryFillRect.setAttribute('x', x);
-                        memoryFillRect.setAttribute('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight));
-                        memoryFillRect.setAttribute('width', iconBaseWidth);
-                        memoryFillRect.setAttribute('height', memFillActualHeight);
-                        let memFillColor = 'var(--exo-yellow)';
-                        if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
-                        memoryFillRect.style.fill = memFillColor;
-                        memoryFillRect.style.opacity = '0.75';
-                        memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
-                        specificIconGroup.appendChild(memoryFillRect);
-                    }
-
-                    // Front Panel Details for Mac Mini (No horizontal slot)
-                    const detailColor = 'rgba(0,0,0,0.3)';
-                    const slotHeight = iconBaseHeight * 0.18; // Adjusted for flatter mini
-                    const slotCornerRadius = 1.2;
-
-                    // Vertical slots (2)
-                    const vSlotWidth = iconBaseWidth * 0.045;
-                    const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.55 - slotHeight / 2;
-                    const vSlot1X = x + iconBaseWidth * 0.18; // Adjusted positioning for wider mini
-                    const vSlot2X = x + iconBaseWidth * 0.28;
-
-                    [vSlot1X, vSlot2X].forEach(vx => {
-                        const vSlot = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                        vSlot.setAttribute('x', vx - vSlotWidth / 2);
-                        vSlot.setAttribute('y', vSlotY);
-                        vSlot.setAttribute('width', vSlotWidth);
-                        vSlot.setAttribute('height', slotHeight);
-                        vSlot.setAttribute('fill', detailColor);
-                        vSlot.setAttribute('rx', slotCornerRadius);
-                        specificIconGroup.appendChild(vSlot);
-                    });
-
-                    // LED indicator for Mac Mini
-                    const ledRadius = iconBaseWidth * 0.022;
-                    const ledX = x + iconBaseWidth * 0.82; // Adjusted positioning
-                    const ledY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
-                    const led = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
-                    led.setAttribute('cx', ledX);
-                    led.setAttribute('cy', ledY);
-                    led.setAttribute('r', ledRadius);
-                    led.setAttribute('fill', 'var(--exo-light-gray)');
-                    specificIconGroup.appendChild(led);
-
-                } else if (modelId.toLowerCase() === "macbook pro") {
-                    iconBaseWidth = nodeRadius * 1.6;  // Max width of the base
-                    iconBaseHeight = nodeRadius * 1.15; // Overall height of the open laptop visual
-                    const x = nodeInfo.x - iconBaseWidth / 2;
-                    const y = nodeInfo.y - iconBaseHeight / 2;
-
-                    const screenCornerRadius = 3;
-                    const baseCornerRadius = 2;
-                    const screenBezel = 1.5;
-
-                    const perspectiveFactor = 0.15; // How much wider the back of the base is than the front
-                    const frontWidth = iconBaseWidth * (1 - perspectiveFactor);
-                    const backWidth = iconBaseWidth;
-
-                    const screenActualHeight = iconBaseHeight * 0.70;
-                    const baseActualHeight = iconBaseHeight * 0.30;
-                    const trackpadHeight = baseActualHeight * 0.35;
-                    const keyboardAreaHeight = baseActualHeight * 0.50;
-
-                    // Define ClipPath for the screen content area
-                    let defs = nodeG.querySelector('defs');
-                    if (!defs) {
-                        defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
-                        nodeG.appendChild(defs);
-                    }
-                    const clipPath = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath');
-                    clipPath.setAttribute('id', clipPathId);
-                    const clipRectScreen = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    clipRectScreen.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 + screenBezel); // Screen aligned with front of base
-                    clipRectScreen.setAttribute('y', y + screenBezel);
-                    clipRectScreen.setAttribute('width', frontWidth - (2 * screenBezel));
-                    clipRectScreen.setAttribute('height', screenActualHeight - (2 * screenBezel));
-                    clipRectScreen.setAttribute('rx', screenCornerRadius -1 > 0 ? screenCornerRadius -1 : 0);
-                    clipPath.appendChild(clipRectScreen);
-                    defs.appendChild(clipPath);
-
-                    // Laptop Base (trapezoidal shape for perspective)
-                    const basePath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
-                    const baseY = y + screenActualHeight;
-                    // Recalculate coordinates for correct perspective
-                    const x_back_left = x;
-                    const x_back_right = x + backWidth;
-                    const x_front_left = x + (backWidth - frontWidth) / 2;
-                    const x_front_right = x + (backWidth + frontWidth) / 2;
-                    const y_top_base = baseY;
-                    const y_bottom_base = baseY + baseActualHeight;
-
-                    const pathData = `
-                        M ${x_front_left} ${y_top_base}
-                        L ${x_front_right} ${y_top_base}
-                        L ${x_back_right} ${y_bottom_base}
-                        L ${x_back_left} ${y_bottom_base}
-                        Z
-                    `; // Correct trapezoid path
-                    basePath.setAttribute('d', pathData.trim().replace(/\s+/g, ' '));
-                    basePath.style.fill = fillCol; // Status color for the base
-                    basePath.setAttribute('stroke', 'var(--exo-light-gray)');
-                    basePath.setAttribute('stroke-width', '1px');
-                    specificIconGroup.appendChild(basePath);
-
-                    // Keyboard Area
-                    const keyboardX = x + (iconBaseWidth - frontWidth)/2 + frontWidth * 0.05;
-                    const keyboardY = baseY + baseActualHeight * 0.05;
-                    const keyboardWidth = frontWidth * 0.9;
-                    const keyboard = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    keyboard.setAttribute('x', keyboardX);
-                    keyboard.setAttribute('y', keyboardY);
-                    keyboard.setAttribute('width', keyboardWidth);
-                    keyboard.setAttribute('height', keyboardAreaHeight);
-                    keyboard.setAttribute('fill', 'rgba(0,0,0,0.1)'); // Darker area for keyboard
-                    keyboard.setAttribute('rx', baseCornerRadius);
-                    specificIconGroup.appendChild(keyboard);
-
-                    // Trackpad Area
-                    const trackpadWidth = frontWidth * 0.4;
-                    const trackpadX = nodeInfo.x - trackpadWidth/2; // Centered
-                    const trackpadY = baseY + keyboardAreaHeight + baseActualHeight * 0.08;
-                    const trackpad = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    trackpad.setAttribute('x', trackpadX);
-                    trackpad.setAttribute('y', trackpadY);
-                    trackpad.setAttribute('width', trackpadWidth);
-                    trackpad.setAttribute('height', trackpadHeight);
-                    trackpad.setAttribute('fill', 'rgba(255,255,255,0.1)'); // Lighter area for trackpad
-                    trackpad.setAttribute('rx', baseCornerRadius);
-                    specificIconGroup.appendChild(trackpad);
-
-                    // Screen Outline & Background (drawn on top of base part that might go under it)
-                    const screenOuter = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    screenOuter.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 ); // Screen aligned with front of base
-                    screenOuter.setAttribute('y', y);
-                    screenOuter.setAttribute('width', frontWidth);
-                    screenOuter.setAttribute('height', screenActualHeight);
-                    screenOuter.setAttribute('rx', screenCornerRadius);
-                    screenOuter.setAttribute('ry', screenCornerRadius);
-                    screenOuter.style.fill = 'var(--exo-dark-gray)'; // Dark screen area
-                    screenOuter.setAttribute('stroke', 'var(--exo-light-gray)');
-                    screenOuter.setAttribute('stroke-width', '1.5px');
-                    specificIconGroup.appendChild(screenOuter);
-
-                    // Memory Fill in Screen Area (Clipped)
-                    if (ramUsagePercent > 0) {
-                        const memFillTotalHeight = screenActualHeight - (2 * screenBezel);
-                        const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
-                        const memoryFillRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                        memoryFillRect.setAttribute('x', x + (iconBaseWidth - frontWidth)/2 + screenBezel);
-                        memoryFillRect.setAttribute('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight)); // From bottom up
-                        memoryFillRect.setAttribute('width', frontWidth - (2 * screenBezel));
-                        memoryFillRect.setAttribute('height', memFillActualHeight);
-                        let memFillColor = 'var(--exo-yellow)';
-                        if (fillCol === 'var(--exo-yellow)') memFillColor = 'var(--exo-yellow-darker)';
-                        memoryFillRect.style.fill = memFillColor;
-                        memoryFillRect.style.opacity = '0.9';
-                        memoryFillRect.setAttribute('clip-path', `url(#${clipPathId})`);
-                        specificIconGroup.appendChild(memoryFillRect);
-                    }
-
-                    // Apple Logo on Screen (on top of memory fill)
-                    const logoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
-                    logoPath.setAttribute('d', APPLE_LOGO_PATH_SIMPLE);
-                    const targetLogoHeightMBP = screenActualHeight * 0.18;
-                    const logoScale = targetLogoHeightMBP / LOGO_NATIVE_HEIGHT; // Correct scale calculation
-                    const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
-                    const logoY = y + screenActualHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2); // Centered Y
-                    logoPath.setAttribute('transform', `translate(${logoX} ${logoY}) scale(${logoScale})`);
-                    logoPath.setAttribute('fill', '#FFFFFF');
-                    specificIconGroup.appendChild(logoPath);
-
-                } else { // Default/Unknown
-                    // Default/Unknown Icon - A simple rounded rectangle
-                    const unknownIconWidth = iconBaseWidth * 0.8;
-                    const unknownIconHeight = iconBaseHeight * 0.8;
-                    const unknown = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    unknown.setAttribute('x', nodeInfo.x - unknownIconWidth / 2);
-                    unknown.setAttribute('y', nodeInfo.y - unknownIconHeight / 2);
-                    unknown.setAttribute('width', unknownIconWidth);
-                    unknown.setAttribute('height', unknownIconHeight);
-                    unknown.style.fill = fillCol;
-                    unknown.setAttribute('stroke', 'var(--exo-light-gray)');
-                    unknown.setAttribute('stroke-width', '1px');
-                    unknown.setAttribute('rx', '3');
-                    specificIconGroup.appendChild(unknown);
-                }
-                nodeG.appendChild(specificIconGroup);
-                // --- End Device Specific Icon Drawing ---
-
-                // --- Memory Text (Below Icon) ---
-                const nodeMemoryText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-                nodeMemoryText.setAttribute('x', nodeInfo.x);
-                nodeMemoryText.setAttribute('y', nodeInfo.y + iconBaseHeight / 2 + 14); // Position below icon, increased offset
-                nodeMemoryText.setAttribute('class', 'node-memory-text');
-                nodeMemoryText.innerHTML = `
-                    <tspan x="${nodeInfo.x}" dy="1em">${ramUsedFormatted}/${ramTotalFormatted}</tspan>
-                    <tspan x="${nodeInfo.x}" dy="1.2em">(${ramUsagePercent.toFixed(0)}%)</tspan>
-                `;
-                nodeG.appendChild(nodeMemoryText);
-
-                // --- Friendly Name Text (Above Icon) ---
-                const friendlyNameText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-                friendlyNameText.setAttribute('x', nodeInfo.x);
-                friendlyNameText.setAttribute('y', nodeInfo.y - iconBaseHeight / 2 - 15); // Position above icon
-                friendlyNameText.setAttribute('text-anchor', 'middle');
-                friendlyNameText.style.fontSize = '18px';
-                friendlyNameText.style.fill = 'var(--exo-light-gray)';
-                const friendlyName = node.friendly_name || nodeId.substring(0, 8) + '...';
-                friendlyNameText.textContent = friendlyName;
-                nodeG.appendChild(friendlyNameText);
-
-                // --- Vertical GPU Bar (Next to Icon) ---
-                const gpuVerticalBarWidth = 24; // Increased width
-                const gpuVerticalBarHeight = iconBaseHeight * 0.95; // Relative to icon height
-                const barXOffset = iconBaseWidth / 2 + 18; // Increased offset from icon's right edge
-                const gpuBarX = nodeInfo.x + barXOffset;
-                const gpuBarY = nodeInfo.y - gpuVerticalBarHeight / 2; // Centered vertically with icon
-
-                // GPU Bar Background
-                const gpuBarBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                gpuBarBg.setAttribute('x', gpuBarX);
-                gpuBarBg.setAttribute('y', gpuBarY);
-                gpuBarBg.setAttribute('width', gpuVerticalBarWidth);
-                gpuBarBg.setAttribute('height', gpuVerticalBarHeight);
-                gpuBarBg.setAttribute('fill', 'var(--exo-medium-gray)');
-                gpuBarBg.setAttribute('rx', 2);
-                nodeG.appendChild(gpuBarBg);
-
-                // GPU Bar Fill
-                if (gpuUsagePercent > 0) {
-                    const fillHeight = (gpuUsagePercent / 100) * gpuVerticalBarHeight;
-                    const gpuBarFill = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
-                    gpuBarFill.setAttribute('x', gpuBarX);
-                    gpuBarFill.setAttribute('y', gpuBarY + (gpuVerticalBarHeight - fillHeight)); // Fills from bottom up
-                    gpuBarFill.setAttribute('width', gpuVerticalBarWidth);
-                    gpuBarFill.setAttribute('height', fillHeight);
-                    gpuBarFill.setAttribute('fill', getGpuBarColor(gpuTempForColor));
-                    gpuBarFill.setAttribute('rx', 2);
-                    nodeG.appendChild(gpuBarFill);
-                }
-
-                // GPU Percentage and Temperature Text on Bar (multiline)
-                const gpuInfoOnBarText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-                gpuInfoOnBarText.setAttribute('x', gpuBarX + gpuVerticalBarWidth / 2);
-                gpuInfoOnBarText.setAttribute('y', gpuBarY + gpuVerticalBarHeight / 2); // Base Y for centering
-                gpuInfoOnBarText.setAttribute('class', 'gpu-temp-on-bar');
-                gpuInfoOnBarText.style.dominantBaseline = 'central'; // Helps with overall vertical alignment
-
-                const gpuUsageText = gpuUsagePercent > 0 ? `${gpuUsagePercent.toFixed(0)}%` : '-';
-                const tempText = gpuTempDisplay === 'N/A' ? '-' : gpuTempDisplay;
-                const powerText = sysPower; // Already formatted with " W" or "N/A"
-
-                gpuInfoOnBarText.innerHTML = `
-                    <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="-1.1em">${gpuUsageText}</tspan>
-                    <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="1.2em">${tempText}</tspan>
-                    <tspan x="${gpuBarX + gpuVerticalBarWidth / 2}" dy="1.2em">${powerText}</tspan>
-                `;
-                nodeG.appendChild(gpuInfoOnBarText);
-
-                // Add click listener to the group
-                nodeG.addEventListener('click', () => {
-                    currentlySelectedNodeId = nodeId; // Set the globally selected node
-                    showNodeDetails(nodeId, nodesData);
-                });
-
-                nodesGroup.appendChild(nodeG);
-            });
-            // Draw order: lines at the very back, then edge labels, then nodes, then mid-line arrows on top
-            topologyGraphContainer.appendChild(linksGroup);
-            topologyGraphContainer.appendChild(edgeLabelsGroup);
-            topologyGraphContainer.appendChild(nodesGroup);
-            topologyGraphContainer.appendChild(arrowsGroup);
-        }
-
-        function showNodeDetails(selectedNodeId, allNodesData) {
-            const nodeData = allNodesData[selectedNodeId];
-            if (!nodeData) return;
-
-            detailFriendlyName.textContent = nodeData.friendly_name || selectedNodeId;
-            if (nodeData.friendly_name) {
-                detailNodeId.textContent = selectedNodeId;
-                detailNodeId.style.display = 'block';
-            } else {
-                detailNodeId.style.display = 'none';
-            }
-
-            let ramUsagePercent = 0, cpuUsagePercent = 0, gpuUsagePercent = 0;
-            let aneActive = false;
-            let sysPower = 'N/A', cpuTemp = 'N/A', gpuTemp = 'N/A';
-            let primaryIp = nodeData.addrs && nodeData.addrs.length > 0 ? nodeData.addrs[0] : 'N/A';
-            let otherIpCount = nodeData.addrs && nodeData.addrs.length > 1 ? ` (+${nodeData.addrs.length - 1})` : '';
-
-            const macmon = nodeData.macmon_info;
-            let ramUsageFormatted = '0 Bytes';
-            let ramTotalFormatted = '0 Bytes';
-
-            if (macmon) {
-                if (macmon.memory && macmon.memory.ram_total > 0) {
-                    ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
-                    ramUsageFormatted = formatBytes(macmon.memory.ram_usage, 1);
-                    ramTotalFormatted = formatBytes(macmon.memory.ram_total, 1);
-                }
-                if (macmon.pcpu_usage && macmon.ecpu_usage) {
-                    cpuUsagePercent = ((macmon.pcpu_usage[1] + macmon.ecpu_usage[1]) / 2) * 100;
-                }
-                if (macmon.gpu_usage) {
-                    gpuUsagePercent = macmon.gpu_usage[1] * 100;
-                }
-                aneActive = macmon.ane_power > 0;
-                if (macmon.sys_power) sysPower = `${macmon.sys_power.toFixed(1)} W`;
-                if (macmon.temp) {
-                    if (macmon.temp.cpu_temp_avg) cpuTemp = `${macmon.temp.cpu_temp_avg.toFixed(1)}°C`;
-                    if (typeof macmon.temp.gpu_temp_avg === 'number') { // Check type for robustness
-                        const actualGpuTemp = macmon.temp.gpu_temp_avg;
-                        const clampedGpuTemp = Math.max(30, actualGpuTemp);
-                        gpuTemp = `${clampedGpuTemp.toFixed(1)}°C`;
-                    } // gpuTemp remains 'N/A' if not set or not a number
-                }
-            }
-
-            detailContent.innerHTML = `
-                <div class="info-item">
-                    <span class="label">IP Address</span>
-                    <span class="value">${primaryIp}${otherIpCount}</span>
-                </div>
-                <div class="info-item">
-                    <span class="label">Total Unified Memory</span>
-                    <span class="value">${formatBytes(nodeData.mem)}</span>
-                </div>
-                <div class="info-item">
-                    <span class="label">System Power</span>
-                    <span class="value">${sysPower}</span>
-                </div>
-                <div class="info-item">
-                    <span class="label">ANE</span>
-                    <span class="value ${aneActive ? 'accent' : ''}">${aneActive ? 'Active' : 'Idle'}</span>
-                </div>
-                <div class="info-item">
-                    <span class="label">CPU Temp</span>
-                    <span class="value">${cpuTemp}</span>
-                </div>
-                <div class="info-item">
-                    <span class="label">GPU Temp</span>
-                    <span class="value">${gpuTemp}</span>
-                </div>
-                <hr style="border-color: var(--exo-medium-gray); margin: 15px 0;">
-                <div class="info-item">
-                    <span class="label">Unified Memory Usage (${ramUsageFormatted} / ${ramTotalFormatted})</span>
-                    <div class="progress-bar-container">
-                        <div class="progress-bar" style="width: ${ramUsagePercent.toFixed(2)}%;"></div>
-                    </div>
-                </div>
-                <div class="info-item">
-                    <span class="label">CPU Usage (${cpuUsagePercent.toFixed(1)}%)</span>
-                    <div class="progress-bar-container">
-                        <div class="progress-bar" style="width: ${cpuUsagePercent.toFixed(2)}%;"></div>
-                    </div>
-                </div>
-                <div class="info-item">
-                    <span class="label">GPU Usage (${gpuUsagePercent.toFixed(1)}%)</span>
-                    <div class="progress-bar-container">
-                        <div class="progress-bar" style="width: ${gpuUsagePercent.toFixed(2)}%;"></div>
-                    </div>
-                </div>
-                <div class="info-item" style="margin-top: 20px; font-size:0.8em; opacity:0.7;">
-                    <span class="label">Last macmon Update</span>
-                    <span class="value">${timeSince(nodeData.last_macmon_update)} (raw: ${nodeData.last_macmon_update})</span>
-                </div>
-            `;
-
-            nodeDetailPanel.classList.add('visible');
-        }
-
-        if (closeDetailPanelButton) {
-            closeDetailPanelButton.addEventListener('click', () => {
-                nodeDetailPanel.classList.remove('visible');
-                currentlySelectedNodeId = null; // Clear selected node on manual close
-            });
-        }
-
-        // Set up sidebar toggle functionality
-        if (instancesMenuButton) {
-            instancesMenuButton.addEventListener('click', toggleSidebar);
-        }
-
-        // Set up launch instance functionality
-        if (modelSelect) {
-            modelSelect.addEventListener('change', () => {
-                launchInstanceButton.disabled = !modelSelect.value;
-            });
-        }
-
-        if (launchInstanceButton) {
-            launchInstanceButton.addEventListener('click', launchInstance);
-        }
-
-        let isFetching = false; // Lock to prevent multiple concurrent fetches
-        let fetchIntervalId = null;
-
-        async function fetchDataAndRender() {
-            if (isFetching) {
-                // console.log("Already fetching, skipping this interval.");
-                return;
-            }
-            isFetching = true;
-            // console.log(`Fetching data from: ${API_ENDPOINT}`); // Less verbose for 1s interval
-            if (lastUpdatedElement.textContent === 'Fetching data...' || !lastUpdatedElement.textContent.startsWith("Last updated:")) {
-                lastUpdatedElement.textContent = 'Fetching data...';
-            }
-
-            try {
-                const urlWithCacheBuster = new URL(API_ENDPOINT);
-                urlWithCacheBuster.searchParams.set('_cb', new Date().getTime()); // _cb for cache buster
-
-                const response = await fetch(urlWithCacheBuster.toString(), { cache: 'no-store' }); // Keep no-store too
-                if (!response.ok) {
-                    throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
-                }
-                const clusterState = await response.json();
-                const topologyData = transformClusterStateToTopology(clusterState);
-                // Build nodeId -> friendly name map
-                nodeIdToFriendlyName = {};
-                if (topologyData && topologyData.nodes) {
-                    Object.keys(topologyData.nodes).forEach(nid => {
-                        const n = topologyData.nodes[nid];
-                        const name = (n && (n.friendly_name || (n.system_info && n.system_info.model_id))) || null;
-                        if (name) nodeIdToFriendlyName[nid] = name;
-                    });
-                }
-                renderNodes(topologyData);
-
-                // If a node was selected, and it still exists, refresh its details
-                if (currentlySelectedNodeId && topologyData.nodes[currentlySelectedNodeId]) {
-                    showNodeDetails(currentlySelectedNodeId, topologyData.nodes);
-                } else if (currentlySelectedNodeId && !topologyData.nodes[currentlySelectedNodeId]) {
-                    // If selected node is gone, close panel and clear selection
-                    nodeDetailPanel.classList.remove('visible');
-                    currentlySelectedNodeId = null;
-                }
-
-                lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
-            } catch (error) {
-                console.error("Failed to fetch topology data:", error);
-                if (topologyGraphContainer) {
-                    topologyGraphContainer.innerHTML = ''; // Clear previous content
-                    const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-                    textEl.setAttribute('x', '50%');
-                    textEl.setAttribute('y', '50%');
-                    textEl.setAttribute('alignment-baseline', 'middle');
-                    textEl.setAttribute('text-anchor', 'middle');
-                    textEl.setAttribute('fill', '#FFD700'); // Yellow color
-                    textEl.setAttribute('font-size', '14');
-                    textEl.textContent = `Connect a Mac to start`;
-                    topologyGraphContainer.appendChild(textEl);
-                }
-                lastUpdatedElement.textContent = `Update failed: ${new Date().toLocaleTimeString()}`;
-                // If fetch fails, also consider hiding the panel if a node was selected
-                if (currentlySelectedNodeId) {
-                    nodeDetailPanel.classList.remove('visible');
-                    currentlySelectedNodeId = null;
-                }
-            } finally {
-                isFetching = false;
-            }
-        }
-
-        // Transform ClusterState -> nodesData compatible with existing render logic
-        function _extractIpFromMultiaddr(ma) {
-            if (!ma || typeof ma !== 'string') return null;
-            const parts = ma.split('/');
-            const ip4Idx = parts.indexOf('ip4');
-            const ip6Idx = parts.indexOf('ip6');
-            const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
-            if (idx >= 0 && parts.length > idx + 1) {
-                return parts[idx + 1];
-            }
-            return null;
-        }
-
-        function transformClusterStateToTopology(clusterState) {
-            const resultNodes = {};
-            const resultEdges = [];
-            if (!clusterState) return { nodes: resultNodes, edges: resultEdges };
-
-            // Helper: get numeric bytes from various shapes (number | {in_bytes}|{inBytes})
-            function getBytes(value) {
-                if (typeof value === 'number') return value;
-                if (value && typeof value === 'object') {
-                    if (typeof value.in_bytes === 'number') return value.in_bytes;
-                    if (typeof value.inBytes === 'number') return value.inBytes;
-                }
-                return 0;
-            }
-
-            // Helper: pick from snake_case or camelCase
-            const pick = (obj, snake, camel, fallback = undefined) => {
-                if (!obj) return fallback;
-                if (obj[snake] !== undefined) return obj[snake];
-                if (obj[camel] !== undefined) return obj[camel];
-                return fallback;
-            };
-
-            // Helper: detect API placeholders like "unknown" (case-insensitive)
-            const isUnknown = (value) => {
-                return typeof value === 'string' && value.trim().toLowerCase() === 'unknown';
-            };
-
-            // Process nodes from topology or fallback to nodeProfiles directly (support both snake_case and camelCase)
-            let nodesToProcess = {};
-            if (clusterState.topology && Array.isArray(clusterState.topology.nodes)) {
-                clusterState.topology.nodes.forEach(node => {
-                    const nid = node.nodeId ?? node.node_id;
-                    const nprof = node.nodeProfile ?? node.node_profile;
-                    if (nid && nprof) {
-                        nodesToProcess[nid] = nprof;
-                    }
-                });
-            } else if (clusterState.nodeProfiles || clusterState.node_profiles) {
-                nodesToProcess = clusterState.nodeProfiles || clusterState.node_profiles;
-            }
-
-            // Transform each node
-            for (const nodeId in nodesToProcess) {
-                const nodeProfile = nodesToProcess[nodeId];
-                if (!nodeProfile) continue;
-
-                // Extract memory information (supports new nested schema and old flat numbers)
-                let memBytesTotal = 0;
-                let memBytesAvailable = 0;
-                const memory = nodeProfile.memory || {};
-                const ramTotalVal = pick(memory, 'ram_total', 'ramTotal');
-                const ramAvailVal = pick(memory, 'ram_available', 'ramAvailable');
-                const swapTotalVal = pick(memory, 'swap_total', 'swapTotal');
-                const swapAvailVal = pick(memory, 'swap_available', 'swapAvailable');
-
-                memBytesTotal = getBytes(ramTotalVal);
-                memBytesAvailable = getBytes(ramAvailVal);
-                const memBytesUsed = Math.max(memBytesTotal - memBytesAvailable, 0);
-
-                // Extract model information with graceful placeholders while node is loading
-                const rawModelId = pick(nodeProfile, 'model_id', 'modelId', 'Unknown');
-                const rawChipId = pick(nodeProfile, 'chip_id', 'chipId', '');
-                const rawFriendlyName = pick(nodeProfile, 'friendly_name', 'friendlyName', `${nodeId.substring(0, 8)}...`);
-
-                // When API has not fully loaded (reports "unknown"), present a nice default
-                const modelId = isUnknown(rawModelId) ? 'Mac Studio' : rawModelId;
-                const chipId = isUnknown(rawChipId) ? '' : rawChipId;
-                const friendlyName = (!rawFriendlyName || isUnknown(rawFriendlyName)) ? 'Mac' : rawFriendlyName;
-
-                // Extract network addresses (support snake_case and camelCase)
-                const addrList = [];
-                const netIfacesSnake = nodeProfile.network_interfaces;
-                const netIfacesCamel = nodeProfile.networkInterfaces;
-                const interfaces = Array.isArray(netIfacesSnake) ? netIfacesSnake : (Array.isArray(netIfacesCamel) ? netIfacesCamel : []);
-                interfaces.forEach(intf => {
-                    const ip = intf.ip_address ?? intf.ipAddress;
-                    if (ip && !String(ip).startsWith('fe80::')) {
-                        addrList.push(ip);
-                    }
-                });
-
-                // Transform system metrics to macmon_info format (support snake_case and camelCase)
-                const systemInfo = nodeProfile.system || {};
-                const gpuUsage = pick(systemInfo, 'gpu_usage', 'gpuUsage', 0);
-                const temp = pick(systemInfo, 'temp', 'temp', null);
-                const sysPower = pick(systemInfo, 'sys_power', 'sysPower', null);
-                const pcpuUsage = pick(systemInfo, 'pcpu_usage', 'pcpuUsage', 0);
-                const ecpuUsage = pick(systemInfo, 'ecpu_usage', 'ecpuUsage', 0);
-                const anePower = pick(systemInfo, 'ane_power', 'anePower', 0);
-                const flopsFp16 = pick(systemInfo, 'flops_fp16', 'flopsFp16', 0);
-
-                const macmonInfo = {
-                    memory: {
-                        ram_total: memBytesTotal,
-                        ram_usage: memBytesUsed,
-                        ram_available: memBytesAvailable,
-                        swap_total: getBytes(swapTotalVal),
-                        swap_usage: Math.max(getBytes(swapTotalVal) - getBytes(swapAvailVal), 0)
-                    },
-                    gpu_usage: [0, typeof gpuUsage === 'number' ? gpuUsage : 0],
-                    temp: {
-                        cpu_temp_avg: typeof temp === 'number' ? temp : null,
-                        gpu_temp_avg: typeof temp === 'number' ? temp : null
-                    },
-                    sys_power: typeof sysPower === 'number' ? sysPower : null,
-                    pcpu_usage: [0, typeof pcpuUsage === 'number' ? pcpuUsage : 0],
-                    ecpu_usage: [0, typeof ecpuUsage === 'number' ? ecpuUsage : 0],
-                    ane_power: typeof anePower === 'number' ? anePower : 0,
-                    flops_fp16: typeof flopsFp16 === 'number' ? flopsFp16 : 0,
-                    timestamp: new Date().toISOString()
-                };
-
-                resultNodes[nodeId] = {
-                    mem: memBytesTotal,
-                    addrs: addrList,
-                    last_addr_update: Date.now() / 1000,
-                    system_info: {
-                        model_id: modelId,
-                        chip_id: chipId
-                    },
-                    macmon_info: macmonInfo,
-                    last_macmon_update: Date.now() / 1000,
-                    friendly_name: friendlyName
-                };
-            }
-
-            // Extract directed edges from topology.connections if present (support camelCase)
-            const connections = clusterState.topology && Array.isArray(clusterState.topology.connections)
-                ? clusterState.topology.connections
-                : [];
-            connections.forEach(conn => {
-                if (!conn) return;
-                const src = conn.localNodeId ?? conn.local_node_id;
-                const dst = conn.sendBackNodeId ?? conn.send_back_node_id;
-                if (!src || !dst) return;
-                if (!resultNodes[src] || !resultNodes[dst]) return; // only draw edges between known nodes
-                if (src === dst) return; // skip self loops for now
-                
-                // Extract address information from connection
-                const sendBackMultiaddr = conn.sendBackMultiaddr ?? conn.send_back_multiaddr;
-                
-                // Extract IP from sendBackMultiaddr object
-                // It might have properties like 'multiaddr' or be serialized differently
-                let sendBackAddrString = null;
-                if (sendBackMultiaddr) {
-                    // Try different possible field names
-                    sendBackAddrString = sendBackMultiaddr.multiaddr ?? 
-                                        sendBackMultiaddr.address ?? 
-                                        sendBackMultiaddr.addr ??
-                                        (typeof sendBackMultiaddr === 'string' ? sendBackMultiaddr : null);
-                    
-                    // If it's still an object, try to convert to string
-                    if (!sendBackAddrString && typeof sendBackMultiaddr === 'object') {
-                        sendBackAddrString = sendBackMultiaddr.toString?.() ?? JSON.stringify(sendBackMultiaddr);
-                    }
-                }
-                
-                // Extract IP from the multiaddr string
-                const sendBackIp = _extractIpFromMultiaddr(sendBackAddrString);
-                
-                // Try to map IP to interface name on destination node
-                let sendBackInterface = null;
-                
-                if (sendBackIp && resultNodes[dst]) {
-                    const dstNode = nodesToProcess[dst];
-                    if (dstNode) {
-                        const netIfacesSnake = dstNode.network_interfaces;
-                        const netIfacesCamel = dstNode.networkInterfaces;
-                        const interfaces = Array.isArray(netIfacesSnake) ? netIfacesSnake : (Array.isArray(netIfacesCamel) ? netIfacesCamel : []);
-                        const matchingIface = interfaces.find(intf => {
-                            const ip = intf.ip_address ?? intf.ipAddress;
-                            return ip === sendBackIp;
-                        });
-                        if (matchingIface) {
-                            sendBackInterface = matchingIface.name ?? matchingIface.interface_name ?? matchingIface.interfaceName;
-                        }
-                    }
-                }
-                
-                resultEdges.push({ 
-                    source: src, 
-                    target: dst,
-                    sendBackIp: sendBackIp,
-                    sendBackInterface: sendBackInterface,
-                    multiaddr: sendBackAddrString
-                });
-            });
-
-            return { nodes: resultNodes, edges: resultEdges };
-        }
-
-        // --- Conditional Data Handling ---
-        if (!USE_MOCK_DATA) {
-            // Initial fetch for live data
-            fetchDataAndRender();
-            fetchAndRenderInstances();
-            fetchAndPopulateModels();
-            // Periodic refresh for live data
-            fetchIntervalId = setInterval(fetchDataAndRender, REFRESH_INTERVAL);
-            setInterval(fetchAndRenderInstances, REFRESH_INTERVAL);
-        } else {
-            // Use Mock Data
-            lastUpdatedElement.textContent = "Using Mock Data";
-            const mockData = {
-                "mockNodeId1_MacBookPro": {
-                    "mem": 34359738368, // e.g. 32GB
-                    "addrs": ["192.168.1.101", "100.64.0.1"],
-                    "last_addr_update": (Date.now() / 1000) - Math.random() * 60,
-                    "system_info": { "model_id": "MacBook Pro", "chip_id": "Apple M-Series Ultra", "memory": 32768 },
-                    "macmon_info": {
-                        'all_power': 0.0,
-                        'ane_power': 0.0,
-                        'cpu_power': 0.0,
-                        'ecpu_usage': [2048, 0.1], // [absolute, percentage 0-1]
-                        'gpu_power': 0.0,
-                        'gpu_ram_power': 0.0,
-                        'gpu_usage': [384, 0.05],  // [absolute, percentage 0-1]
-                        'memory': {
-                            'ram_total': 34359738368, // 32GB
-                            'ram_usage': 8589934592,  // 8GB
-                            'swap_total': 4294967296, // 4GB
-                            'swap_usage': 0
-                        },
-                        'pcpu_usage': [2048, 0.2], // [absolute, percentage 0-1]
-                        'ram_power': 0.0,
-                        'sys_power': 15.0, // Watts
-                        'temp': {
-                            'cpu_temp_avg': 45.0,
-                            'gpu_temp_avg': 42.0
-                        },
-                        'timestamp': new Date().toISOString()
-                    },
-                    "last_macmon_update": (Date.now() / 1000) - 2,
-                    "friendly_name": "Dev Alpha MBP"
-                },
-                "mockNodeId2_MacMini": {
-                    "mem": 17179869184, // e.g. 16GB
-                    "addrs": ["192.168.1.102"],
-                    "last_addr_update": (Date.now() / 1000) - Math.random() * 120,
-                    "system_info": { "model_id": "Mac Mini", "chip_id": "Apple M-Series Max", "memory": 16384 },
-                    "macmon_info": {
-                        'all_power': 0.0,
-                        'ane_power': 0.0,
-                        'cpu_power': 0.0,
-                        'ecpu_usage': [4096, 0.05],
-                        'gpu_power': 0.0,
-                        'gpu_ram_power': 0.0,
-                        'gpu_usage': [512, 0.02],
-                        'memory': {
-                            'ram_total': 17179869184,
-                            'ram_usage': 4294967296, // 4GB
-                            'swap_total': 0,
-                            'swap_usage': 0
-                        },
-                        'pcpu_usage': [4096, 0.1],
-                        'ram_power': 0.0,
-                        'sys_power': 25.0,
-                        'temp': {
-                            'cpu_temp_avg': 55.0,
-                            'gpu_temp_avg': 50.0
-                        },
-                        'timestamp': new Date().toISOString()
-                    },
-                    "last_macmon_update": (Date.now() / 1000) - 5,
-                    "friendly_name": "Build Server Mini"
-                },
-                "mockNodeId3_MacStudio": { // New Mac Studio Mock Node
-                    "mem": 137438953472, // 128GB
-                    "addrs": ["192.168.1.103"],
-                    "last_addr_update": (Date.now() / 1000) - Math.random() * 30,
-                    "system_info": { "model_id": "Mac Studio", "chip_id": "Apple M-Ultra Fusion Max Pro", "memory": 131072 },
-                    "macmon_info": {
-                        'all_power': 0.0,
-                        'ane_power': 0.0,
-                        'cpu_power': 0.0,
-                        'ecpu_usage': [8192, 0.02],
-                        'gpu_power': 0.0,
-                        'gpu_ram_power': 0.0,
-                        'gpu_usage': [1024, 0.01],
-                        'memory': {
-                            'ram_total': 137438953472,
-                            'ram_usage': 34359738368, // 32GB used
-                            'swap_total': 8589934592,
-                            'swap_usage': 0
-                        },
-                        'pcpu_usage': [8192, 0.05],
-                        'ram_power': 0.0,
-                        'sys_power': 45.0,
-                        'temp': {
-                            'cpu_temp_avg': 60.0,
-                            'gpu_temp_avg': 58.0
-                        },
-                        'timestamp': new Date().toISOString()
-                    },
-                    "last_macmon_update": (Date.now() / 1000) - 3,
-                    "friendly_name": "Graphics Studio Ultra"
-                }
-            };
-
-            function updateMockData() {
-                // Build name map for mock nodes
-                nodeIdToFriendlyName = {};
-                for (const nodeId in mockData) {
-                    nodeIdToFriendlyName[nodeId] = mockData[nodeId].friendly_name || nodeId;
-                }
-                
-                for (const nodeId in mockData) {
-                    const node = mockData[nodeId];
-                    node.last_addr_update = (Date.now() / 1000) - (Math.random() * 10);
-                    node.last_macmon_update = (Date.now() / 1000) - (Math.random() * 3 + 1); // More recent
-
-                    if (node.macmon_info) {
-                        const mi = node.macmon_info;
-                        mi.ecpu_usage[1] = Math.random() * 0.5; // Random % for E-cores
-                        mi.pcpu_usage[1] = Math.random() * 0.8; // Random % for P-cores
-                        mi.gpu_usage[1] = Math.random() * 0.9;  // Random % for GPU
-
-                        mi.memory.ram_usage = Math.random() * mi.memory.ram_total;
-                        if (mi.memory.swap_total > 0) {
-                            mi.memory.swap_usage = Math.random() * mi.memory.swap_total * 0.2; // Less swap usage
-                        }
-
-                        mi.ane_power = Math.random() > 0.6 ? Math.random() * 5 : 0; // Watts, sometimes active
-                        mi.cpu_power = (mi.pcpu_usage[1] * 15) + (mi.ecpu_usage[1] * 5) + (Math.random() * 5); // Approx Watts
-                        mi.gpu_power = (mi.gpu_usage[1] * 20) + (Math.random() * 3); // Approx Watts
-                        mi.ram_power = (mi.memory.ram_usage / mi.memory.ram_total) * 2 + (Math.random() * 0.5); // Approx Watts
-                        mi.all_power = mi.ane_power + mi.cpu_power + mi.gpu_power + mi.ram_power + 5; // Base + components
-                        mi.sys_power = mi.all_power + Math.random() * 2; // sys_power is usually a bit more than all_power sum
-
-                        mi.temp.cpu_temp_avg = 35 + (mi.pcpu_usage[1] + mi.ecpu_usage[1]) * 30 + (Math.random() * 10 - 5);
-                        mi.temp.gpu_temp_avg = 30 + mi.gpu_usage[1] * 50 + (Math.random() * 10 - 5);
-                        mi.timestamp = new Date().toISOString();
-                    }
-                }
-                const mockTopology = { nodes: mockData, edges: [] };
-                renderNodes(mockTopology);
-                lastUpdatedElement.textContent = `Last updated: ${new Date().toLocaleTimeString()} (Mock Data)`;
-
-                if (currentlySelectedNodeId && mockData[currentlySelectedNodeId]) {
-                    showNodeDetails(currentlySelectedNodeId, mockTopology.nodes);
-                } else if (currentlySelectedNodeId && !mockData[currentlySelectedNodeId]) {
-                    nodeDetailPanel.classList.remove('visible');
-                    currentlySelectedNodeId = null;
-                }
-            }
-            updateMockData(); // Initial render with mock
-            setInterval(updateMockData, REFRESH_INTERVAL);
-        }
-
-    </script>
-</body>
-</html>
\ No newline at end of file
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
new file mode 100644
index 00000000..e075d621
--- /dev/null
+++ b/dashboard/package-lock.json
@@ -0,0 +1,3058 @@
+{
+	"name": "exo-dashboard",
+	"version": "1.0.0",
+	"lockfileVersion": 3,
+	"requires": true,
+	"packages": {
+		"": {
+			"name": "exo-dashboard",
+			"version": "1.0.0",
+			"dependencies": {
+				"highlight.js": "^11.11.1",
+				"mode-watcher": "^1.1.0"
+			},
+			"devDependencies": {
+				"@sveltejs/adapter-static": "^3.0.10",
+				"@sveltejs/kit": "^2.48.4",
+				"@sveltejs/vite-plugin-svelte": "^5.0.0",
+				"@tailwindcss/vite": "^4.0.0",
+				"@types/d3": "^7.4.3",
+				"@types/node": "^22",
+				"d3": "^7.9.0",
+				"svelte": "^5.0.0",
+				"svelte-check": "^4.0.0",
+				"tailwindcss": "^4.0.0",
+				"tw-animate-css": "^1.3.5",
+				"typescript": "^5.0.0",
+				"vite": "^6.0.0"
+			}
+		},
+		"node_modules/@esbuild/aix-ppc64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+			"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+			"cpu": [
+				"ppc64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"aix"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/android-arm": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+			"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/android-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+			"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/android-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+			"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/darwin-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+			"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/darwin-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+			"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/freebsd-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+			"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"freebsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/freebsd-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+			"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"freebsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-arm": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+			"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+			"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-ia32": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+			"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+			"cpu": [
+				"ia32"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-loong64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+			"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+			"cpu": [
+				"loong64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-mips64el": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+			"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+			"cpu": [
+				"mips64el"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-ppc64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+			"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+			"cpu": [
+				"ppc64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-riscv64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+			"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+			"cpu": [
+				"riscv64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-s390x": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+			"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+			"cpu": [
+				"s390x"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/linux-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+			"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/netbsd-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+			"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"netbsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/netbsd-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+			"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"netbsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/openbsd-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+			"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"openbsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/openbsd-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+			"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"openbsd"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/openharmony-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+			"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"openharmony"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/sunos-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+			"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"sunos"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/win32-arm64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+			"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/win32-ia32": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+			"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+			"cpu": [
+				"ia32"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@esbuild/win32-x64": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+			"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/@jridgewell/gen-mapping": {
+			"version": "0.3.13",
+			"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+			"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/sourcemap-codec": "^1.5.0",
+				"@jridgewell/trace-mapping": "^0.3.24"
+			}
+		},
+		"node_modules/@jridgewell/remapping": {
+			"version": "2.3.5",
+			"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+			"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/gen-mapping": "^0.3.5",
+				"@jridgewell/trace-mapping": "^0.3.24"
+			}
+		},
+		"node_modules/@jridgewell/resolve-uri": {
+			"version": "3.1.2",
+			"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+			"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6.0.0"
+			}
+		},
+		"node_modules/@jridgewell/sourcemap-codec": {
+			"version": "1.5.5",
+			"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+			"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+			"license": "MIT"
+		},
+		"node_modules/@jridgewell/trace-mapping": {
+			"version": "0.3.31",
+			"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+			"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/resolve-uri": "^3.1.0",
+				"@jridgewell/sourcemap-codec": "^1.4.14"
+			}
+		},
+		"node_modules/@polka/url": {
+			"version": "1.0.0-next.29",
+			"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+			"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@rollup/rollup-android-arm-eabi": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
+			"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			]
+		},
+		"node_modules/@rollup/rollup-android-arm64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
+			"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			]
+		},
+		"node_modules/@rollup/rollup-darwin-arm64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
+			"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			]
+		},
+		"node_modules/@rollup/rollup-darwin-x64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
+			"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			]
+		},
+		"node_modules/@rollup/rollup-freebsd-arm64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
+			"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"freebsd"
+			]
+		},
+		"node_modules/@rollup/rollup-freebsd-x64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
+			"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"freebsd"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
+			"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-arm-musleabihf": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
+			"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-arm64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
+			"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-arm64-musl": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
+			"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-loong64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
+			"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
+			"cpu": [
+				"loong64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-ppc64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
+			"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
+			"cpu": [
+				"ppc64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-riscv64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
+			"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
+			"cpu": [
+				"riscv64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-riscv64-musl": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
+			"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
+			"cpu": [
+				"riscv64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-s390x-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
+			"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
+			"cpu": [
+				"s390x"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-x64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
+			"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-linux-x64-musl": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
+			"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			]
+		},
+		"node_modules/@rollup/rollup-openharmony-arm64": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
+			"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"openharmony"
+			]
+		},
+		"node_modules/@rollup/rollup-win32-arm64-msvc": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
+			"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			]
+		},
+		"node_modules/@rollup/rollup-win32-ia32-msvc": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
+			"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
+			"cpu": [
+				"ia32"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			]
+		},
+		"node_modules/@rollup/rollup-win32-x64-gnu": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
+			"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			]
+		},
+		"node_modules/@rollup/rollup-win32-x64-msvc": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
+			"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			]
+		},
+		"node_modules/@standard-schema/spec": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+			"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@sveltejs/acorn-typescript": {
+			"version": "1.0.8",
+			"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+			"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+			"license": "MIT",
+			"peerDependencies": {
+				"acorn": "^8.9.0"
+			}
+		},
+		"node_modules/@sveltejs/adapter-static": {
+			"version": "3.0.10",
+			"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
+			"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
+			"dev": true,
+			"license": "MIT",
+			"peerDependencies": {
+				"@sveltejs/kit": "^2.0.0"
+			}
+		},
+		"node_modules/@sveltejs/kit": {
+			"version": "2.49.0",
+			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.0.tgz",
+			"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@standard-schema/spec": "^1.0.0",
+				"@sveltejs/acorn-typescript": "^1.0.5",
+				"@types/cookie": "^0.6.0",
+				"acorn": "^8.14.1",
+				"cookie": "^0.6.0",
+				"devalue": "^5.3.2",
+				"esm-env": "^1.2.2",
+				"kleur": "^4.1.5",
+				"magic-string": "^0.30.5",
+				"mrmime": "^2.0.0",
+				"sade": "^1.8.1",
+				"set-cookie-parser": "^2.6.0",
+				"sirv": "^3.0.0"
+			},
+			"bin": {
+				"svelte-kit": "svelte-kit.js"
+			},
+			"engines": {
+				"node": ">=18.13"
+			},
+			"peerDependencies": {
+				"@opentelemetry/api": "^1.0.0",
+				"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
+				"svelte": "^4.0.0 || ^5.0.0-next.0",
+				"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+			},
+			"peerDependenciesMeta": {
+				"@opentelemetry/api": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@sveltejs/vite-plugin-svelte": {
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
+			"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
+				"debug": "^4.4.1",
+				"deepmerge": "^4.3.1",
+				"kleur": "^4.1.5",
+				"magic-string": "^0.30.17",
+				"vitefu": "^1.0.6"
+			},
+			"engines": {
+				"node": "^18.0.0 || ^20.0.0 || >=22"
+			},
+			"peerDependencies": {
+				"svelte": "^5.0.0",
+				"vite": "^6.0.0"
+			}
+		},
+		"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+			"version": "4.0.1",
+			"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
+			"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"debug": "^4.3.7"
+			},
+			"engines": {
+				"node": "^18.0.0 || ^20.0.0 || >=22"
+			},
+			"peerDependencies": {
+				"@sveltejs/vite-plugin-svelte": "^5.0.0",
+				"svelte": "^5.0.0",
+				"vite": "^6.0.0"
+			}
+		},
+		"node_modules/@tailwindcss/node": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
+			"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/remapping": "^2.3.4",
+				"enhanced-resolve": "^5.18.3",
+				"jiti": "^2.6.1",
+				"lightningcss": "1.30.2",
+				"magic-string": "^0.30.21",
+				"source-map-js": "^1.2.1",
+				"tailwindcss": "4.1.17"
+			}
+		},
+		"node_modules/@tailwindcss/oxide": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
+			"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">= 10"
+			},
+			"optionalDependencies": {
+				"@tailwindcss/oxide-android-arm64": "4.1.17",
+				"@tailwindcss/oxide-darwin-arm64": "4.1.17",
+				"@tailwindcss/oxide-darwin-x64": "4.1.17",
+				"@tailwindcss/oxide-freebsd-x64": "4.1.17",
+				"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
+				"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
+				"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
+				"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
+				"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
+				"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
+				"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
+				"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-android-arm64": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
+			"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"android"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-darwin-arm64": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
+			"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-darwin-x64": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
+			"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-freebsd-x64": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
+			"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"freebsd"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
+			"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
+			"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
+			"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
+			"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-linux-x64-musl": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
+			"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-wasm32-wasi": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
+			"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
+			"bundleDependencies": [
+				"@napi-rs/wasm-runtime",
+				"@emnapi/core",
+				"@emnapi/runtime",
+				"@tybys/wasm-util",
+				"@emnapi/wasi-threads",
+				"tslib"
+			],
+			"cpu": [
+				"wasm32"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"dependencies": {
+				"@emnapi/core": "^1.6.0",
+				"@emnapi/runtime": "^1.6.0",
+				"@emnapi/wasi-threads": "^1.1.0",
+				"@napi-rs/wasm-runtime": "^1.0.7",
+				"@tybys/wasm-util": "^0.10.1",
+				"tslib": "^2.4.0"
+			},
+			"engines": {
+				"node": ">=14.0.0"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
+			"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
+			"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/@tailwindcss/vite": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
+			"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@tailwindcss/node": "4.1.17",
+				"@tailwindcss/oxide": "4.1.17",
+				"tailwindcss": "4.1.17"
+			},
+			"peerDependencies": {
+				"vite": "^5.2.0 || ^6 || ^7"
+			}
+		},
+		"node_modules/@types/cookie": {
+			"version": "0.6.0",
+			"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+			"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3": {
+			"version": "7.4.3",
+			"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
+			"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-array": "*",
+				"@types/d3-axis": "*",
+				"@types/d3-brush": "*",
+				"@types/d3-chord": "*",
+				"@types/d3-color": "*",
+				"@types/d3-contour": "*",
+				"@types/d3-delaunay": "*",
+				"@types/d3-dispatch": "*",
+				"@types/d3-drag": "*",
+				"@types/d3-dsv": "*",
+				"@types/d3-ease": "*",
+				"@types/d3-fetch": "*",
+				"@types/d3-force": "*",
+				"@types/d3-format": "*",
+				"@types/d3-geo": "*",
+				"@types/d3-hierarchy": "*",
+				"@types/d3-interpolate": "*",
+				"@types/d3-path": "*",
+				"@types/d3-polygon": "*",
+				"@types/d3-quadtree": "*",
+				"@types/d3-random": "*",
+				"@types/d3-scale": "*",
+				"@types/d3-scale-chromatic": "*",
+				"@types/d3-selection": "*",
+				"@types/d3-shape": "*",
+				"@types/d3-time": "*",
+				"@types/d3-time-format": "*",
+				"@types/d3-timer": "*",
+				"@types/d3-transition": "*",
+				"@types/d3-zoom": "*"
+			}
+		},
+		"node_modules/@types/d3-array": {
+			"version": "3.2.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+			"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-axis": {
+			"version": "3.0.6",
+			"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+			"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-selection": "*"
+			}
+		},
+		"node_modules/@types/d3-brush": {
+			"version": "3.0.6",
+			"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+			"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-selection": "*"
+			}
+		},
+		"node_modules/@types/d3-chord": {
+			"version": "3.0.6",
+			"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+			"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-color": {
+			"version": "3.1.3",
+			"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+			"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-contour": {
+			"version": "3.0.6",
+			"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+			"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-array": "*",
+				"@types/geojson": "*"
+			}
+		},
+		"node_modules/@types/d3-delaunay": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+			"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-dispatch": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+			"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-drag": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+			"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-selection": "*"
+			}
+		},
+		"node_modules/@types/d3-dsv": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+			"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-ease": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+			"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-fetch": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+			"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-dsv": "*"
+			}
+		},
+		"node_modules/@types/d3-force": {
+			"version": "3.0.10",
+			"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
+			"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-format": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
+			"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-geo": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+			"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/geojson": "*"
+			}
+		},
+		"node_modules/@types/d3-hierarchy": {
+			"version": "3.1.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+			"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-interpolate": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+			"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-color": "*"
+			}
+		},
+		"node_modules/@types/d3-path": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+			"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-polygon": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+			"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-quadtree": {
+			"version": "3.0.6",
+			"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+			"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-random": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
+			"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-scale": {
+			"version": "4.0.9",
+			"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+			"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-time": "*"
+			}
+		},
+		"node_modules/@types/d3-scale-chromatic": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+			"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-selection": {
+			"version": "3.0.11",
+			"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+			"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-shape": {
+			"version": "3.1.7",
+			"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
+			"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-path": "*"
+			}
+		},
+		"node_modules/@types/d3-time": {
+			"version": "3.0.4",
+			"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+			"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-time-format": {
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+			"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-timer": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+			"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/d3-transition": {
+			"version": "3.0.9",
+			"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+			"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-selection": "*"
+			}
+		},
+		"node_modules/@types/d3-zoom": {
+			"version": "3.0.8",
+			"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+			"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/d3-interpolate": "*",
+				"@types/d3-selection": "*"
+			}
+		},
+		"node_modules/@types/estree": {
+			"version": "1.0.8",
+			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+			"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+			"license": "MIT"
+		},
+		"node_modules/@types/geojson": {
+			"version": "7946.0.16",
+			"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+			"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/@types/node": {
+			"version": "22.19.1",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
+			"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"undici-types": "~6.21.0"
+			}
+		},
+		"node_modules/acorn": {
+			"version": "8.15.0",
+			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+			"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+			"license": "MIT",
+			"bin": {
+				"acorn": "bin/acorn"
+			},
+			"engines": {
+				"node": ">=0.4.0"
+			}
+		},
+		"node_modules/aria-query": {
+			"version": "5.3.2",
+			"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+			"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">= 0.4"
+			}
+		},
+		"node_modules/axobject-query": {
+			"version": "4.1.0",
+			"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+			"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">= 0.4"
+			}
+		},
+		"node_modules/chokidar": {
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+			"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"readdirp": "^4.0.1"
+			},
+			"engines": {
+				"node": ">= 14.16.0"
+			},
+			"funding": {
+				"url": "https://paulmillr.com/funding/"
+			}
+		},
+		"node_modules/clsx": {
+			"version": "2.1.1",
+			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+			"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/commander": {
+			"version": "7.2.0",
+			"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
+			"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">= 10"
+			}
+		},
+		"node_modules/cookie": {
+			"version": "0.6.0",
+			"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+			"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">= 0.6"
+			}
+		},
+		"node_modules/d3": {
+			"version": "7.9.0",
+			"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
+			"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-array": "3",
+				"d3-axis": "3",
+				"d3-brush": "3",
+				"d3-chord": "3",
+				"d3-color": "3",
+				"d3-contour": "4",
+				"d3-delaunay": "6",
+				"d3-dispatch": "3",
+				"d3-drag": "3",
+				"d3-dsv": "3",
+				"d3-ease": "3",
+				"d3-fetch": "3",
+				"d3-force": "3",
+				"d3-format": "3",
+				"d3-geo": "3",
+				"d3-hierarchy": "3",
+				"d3-interpolate": "3",
+				"d3-path": "3",
+				"d3-polygon": "3",
+				"d3-quadtree": "3",
+				"d3-random": "3",
+				"d3-scale": "4",
+				"d3-scale-chromatic": "3",
+				"d3-selection": "3",
+				"d3-shape": "3",
+				"d3-time": "3",
+				"d3-time-format": "4",
+				"d3-timer": "3",
+				"d3-transition": "3",
+				"d3-zoom": "3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-array": {
+			"version": "3.2.4",
+			"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+			"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"internmap": "1 - 2"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-axis": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
+			"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-brush": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
+			"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-dispatch": "1 - 3",
+				"d3-drag": "2 - 3",
+				"d3-interpolate": "1 - 3",
+				"d3-selection": "3",
+				"d3-transition": "3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-chord": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
+			"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-path": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-color": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+			"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-contour": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
+			"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-array": "^3.2.0"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-delaunay": {
+			"version": "6.0.4",
+			"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+			"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"delaunator": "5"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-dispatch": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+			"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-drag": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+			"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-dispatch": "1 - 3",
+				"d3-selection": "3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-dsv": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
+			"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"commander": "7",
+				"iconv-lite": "0.6",
+				"rw": "1"
+			},
+			"bin": {
+				"csv2json": "bin/dsv2json.js",
+				"csv2tsv": "bin/dsv2dsv.js",
+				"dsv2dsv": "bin/dsv2dsv.js",
+				"dsv2json": "bin/dsv2json.js",
+				"json2csv": "bin/json2dsv.js",
+				"json2dsv": "bin/json2dsv.js",
+				"json2tsv": "bin/json2dsv.js",
+				"tsv2csv": "bin/dsv2dsv.js",
+				"tsv2json": "bin/dsv2json.js"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-ease": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+			"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+			"dev": true,
+			"license": "BSD-3-Clause",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-fetch": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
+			"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-dsv": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-force": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
+			"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-dispatch": "1 - 3",
+				"d3-quadtree": "1 - 3",
+				"d3-timer": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-format": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+			"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-geo": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
+			"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-array": "2.5.0 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-hierarchy": {
+			"version": "3.1.2",
+			"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+			"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-interpolate": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+			"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-color": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-path": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+			"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-polygon": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
+			"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-quadtree": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+			"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-random": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
+			"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-scale": {
+			"version": "4.0.2",
+			"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+			"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-array": "2.10.0 - 3",
+				"d3-format": "1 - 3",
+				"d3-interpolate": "1.2.0 - 3",
+				"d3-time": "2.1.1 - 3",
+				"d3-time-format": "2 - 4"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-scale-chromatic": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+			"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-color": "1 - 3",
+				"d3-interpolate": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-selection": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+			"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-shape": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+			"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-path": "^3.1.0"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-time": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+			"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-array": "2 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-time-format": {
+			"version": "4.1.0",
+			"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+			"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-time": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-timer": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+			"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/d3-transition": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+			"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-color": "1 - 3",
+				"d3-dispatch": "1 - 3",
+				"d3-ease": "1 - 3",
+				"d3-interpolate": "1 - 3",
+				"d3-timer": "1 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			},
+			"peerDependencies": {
+				"d3-selection": "2 - 3"
+			}
+		},
+		"node_modules/d3-zoom": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+			"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"d3-dispatch": "1 - 3",
+				"d3-drag": "2 - 3",
+				"d3-interpolate": "1 - 3",
+				"d3-selection": "2 - 3",
+				"d3-transition": "2 - 3"
+			},
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/debug": {
+			"version": "4.4.3",
+			"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+			"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"ms": "^2.1.3"
+			},
+			"engines": {
+				"node": ">=6.0"
+			},
+			"peerDependenciesMeta": {
+				"supports-color": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/deepmerge": {
+			"version": "4.3.1",
+			"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+			"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/delaunator": {
+			"version": "5.0.1",
+			"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
+			"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+			"dev": true,
+			"license": "ISC",
+			"dependencies": {
+				"robust-predicates": "^3.0.2"
+			}
+		},
+		"node_modules/detect-libc": {
+			"version": "2.1.2",
+			"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+			"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=8"
+			}
+		},
+		"node_modules/devalue": {
+			"version": "5.5.0",
+			"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.5.0.tgz",
+			"integrity": "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w==",
+			"license": "MIT"
+		},
+		"node_modules/enhanced-resolve": {
+			"version": "5.18.3",
+			"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
+			"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"graceful-fs": "^4.2.4",
+				"tapable": "^2.2.0"
+			},
+			"engines": {
+				"node": ">=10.13.0"
+			}
+		},
+		"node_modules/esbuild": {
+			"version": "0.25.12",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+			"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+			"dev": true,
+			"hasInstallScript": true,
+			"license": "MIT",
+			"bin": {
+				"esbuild": "bin/esbuild"
+			},
+			"engines": {
+				"node": ">=18"
+			},
+			"optionalDependencies": {
+				"@esbuild/aix-ppc64": "0.25.12",
+				"@esbuild/android-arm": "0.25.12",
+				"@esbuild/android-arm64": "0.25.12",
+				"@esbuild/android-x64": "0.25.12",
+				"@esbuild/darwin-arm64": "0.25.12",
+				"@esbuild/darwin-x64": "0.25.12",
+				"@esbuild/freebsd-arm64": "0.25.12",
+				"@esbuild/freebsd-x64": "0.25.12",
+				"@esbuild/linux-arm": "0.25.12",
+				"@esbuild/linux-arm64": "0.25.12",
+				"@esbuild/linux-ia32": "0.25.12",
+				"@esbuild/linux-loong64": "0.25.12",
+				"@esbuild/linux-mips64el": "0.25.12",
+				"@esbuild/linux-ppc64": "0.25.12",
+				"@esbuild/linux-riscv64": "0.25.12",
+				"@esbuild/linux-s390x": "0.25.12",
+				"@esbuild/linux-x64": "0.25.12",
+				"@esbuild/netbsd-arm64": "0.25.12",
+				"@esbuild/netbsd-x64": "0.25.12",
+				"@esbuild/openbsd-arm64": "0.25.12",
+				"@esbuild/openbsd-x64": "0.25.12",
+				"@esbuild/openharmony-arm64": "0.25.12",
+				"@esbuild/sunos-x64": "0.25.12",
+				"@esbuild/win32-arm64": "0.25.12",
+				"@esbuild/win32-ia32": "0.25.12",
+				"@esbuild/win32-x64": "0.25.12"
+			}
+		},
+		"node_modules/esm-env": {
+			"version": "1.2.2",
+			"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+			"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+			"license": "MIT"
+		},
+		"node_modules/esrap": {
+			"version": "2.2.1",
+			"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
+			"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/sourcemap-codec": "^1.4.15"
+			}
+		},
+		"node_modules/fdir": {
+			"version": "6.5.0",
+			"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+			"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=12.0.0"
+			},
+			"peerDependencies": {
+				"picomatch": "^3 || ^4"
+			},
+			"peerDependenciesMeta": {
+				"picomatch": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/fsevents": {
+			"version": "2.3.3",
+			"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+			"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+			"dev": true,
+			"hasInstallScript": true,
+			"license": "MIT",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+			}
+		},
+		"node_modules/graceful-fs": {
+			"version": "4.2.11",
+			"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+			"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+			"dev": true,
+			"license": "ISC"
+		},
+		"node_modules/highlight.js": {
+			"version": "11.11.1",
+			"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+			"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+			"license": "BSD-3-Clause",
+			"engines": {
+				"node": ">=12.0.0"
+			}
+		},
+		"node_modules/iconv-lite": {
+			"version": "0.6.3",
+			"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+			"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"safer-buffer": ">= 2.1.2 < 3.0.0"
+			},
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/inline-style-parser": {
+			"version": "0.2.7",
+			"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+			"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+			"license": "MIT"
+		},
+		"node_modules/internmap": {
+			"version": "2.0.3",
+			"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+			"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+			"dev": true,
+			"license": "ISC",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/is-reference": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+			"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+			"license": "MIT",
+			"dependencies": {
+				"@types/estree": "^1.0.6"
+			}
+		},
+		"node_modules/jiti": {
+			"version": "2.6.1",
+			"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+			"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+			"dev": true,
+			"license": "MIT",
+			"bin": {
+				"jiti": "lib/jiti-cli.mjs"
+			}
+		},
+		"node_modules/kleur": {
+			"version": "4.1.5",
+			"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+			"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/lightningcss": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+			"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+			"dev": true,
+			"license": "MPL-2.0",
+			"dependencies": {
+				"detect-libc": "^2.0.3"
+			},
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			},
+			"optionalDependencies": {
+				"lightningcss-android-arm64": "1.30.2",
+				"lightningcss-darwin-arm64": "1.30.2",
+				"lightningcss-darwin-x64": "1.30.2",
+				"lightningcss-freebsd-x64": "1.30.2",
+				"lightningcss-linux-arm-gnueabihf": "1.30.2",
+				"lightningcss-linux-arm64-gnu": "1.30.2",
+				"lightningcss-linux-arm64-musl": "1.30.2",
+				"lightningcss-linux-x64-gnu": "1.30.2",
+				"lightningcss-linux-x64-musl": "1.30.2",
+				"lightningcss-win32-arm64-msvc": "1.30.2",
+				"lightningcss-win32-x64-msvc": "1.30.2"
+			}
+		},
+		"node_modules/lightningcss-android-arm64": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+			"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"android"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-darwin-arm64": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+			"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-darwin-x64": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+			"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"darwin"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-freebsd-x64": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+			"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"freebsd"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-linux-arm-gnueabihf": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+			"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+			"cpu": [
+				"arm"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-linux-arm64-gnu": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+			"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-linux-arm64-musl": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+			"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-linux-x64-gnu": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+			"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-linux-x64-musl": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+			"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"linux"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-win32-arm64-msvc": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+			"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+			"cpu": [
+				"arm64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/lightningcss-win32-x64-msvc": {
+			"version": "1.30.2",
+			"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+			"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+			"cpu": [
+				"x64"
+			],
+			"dev": true,
+			"license": "MPL-2.0",
+			"optional": true,
+			"os": [
+				"win32"
+			],
+			"engines": {
+				"node": ">= 12.0.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/parcel"
+			}
+		},
+		"node_modules/locate-character": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+			"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+			"license": "MIT"
+		},
+		"node_modules/magic-string": {
+			"version": "0.30.21",
+			"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+			"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/sourcemap-codec": "^1.5.5"
+			}
+		},
+		"node_modules/mode-watcher": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
+			"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
+			"license": "MIT",
+			"dependencies": {
+				"runed": "^0.25.0",
+				"svelte-toolbelt": "^0.7.1"
+			},
+			"peerDependencies": {
+				"svelte": "^5.27.0"
+			}
+		},
+		"node_modules/mri": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+			"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=4"
+			}
+		},
+		"node_modules/mrmime": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+			"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=10"
+			}
+		},
+		"node_modules/ms": {
+			"version": "2.1.3",
+			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/nanoid": {
+			"version": "3.3.11",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"license": "MIT",
+			"bin": {
+				"nanoid": "bin/nanoid.cjs"
+			},
+			"engines": {
+				"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+			}
+		},
+		"node_modules/picocolors": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+			"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+			"dev": true,
+			"license": "ISC"
+		},
+		"node_modules/picomatch": {
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=12"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
+		"node_modules/postcss": {
+			"version": "8.5.6",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+			"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+			"dev": true,
+			"funding": [
+				{
+					"type": "opencollective",
+					"url": "https://opencollective.com/postcss/"
+				},
+				{
+					"type": "tidelift",
+					"url": "https://tidelift.com/funding/github/npm/postcss"
+				},
+				{
+					"type": "github",
+					"url": "https://github.com/sponsors/ai"
+				}
+			],
+			"license": "MIT",
+			"dependencies": {
+				"nanoid": "^3.3.11",
+				"picocolors": "^1.1.1",
+				"source-map-js": "^1.2.1"
+			},
+			"engines": {
+				"node": "^10 || ^12 || >=14"
+			}
+		},
+		"node_modules/readdirp": {
+			"version": "4.1.2",
+			"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+			"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">= 14.18.0"
+			},
+			"funding": {
+				"type": "individual",
+				"url": "https://paulmillr.com/funding/"
+			}
+		},
+		"node_modules/robust-predicates": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
+			"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+			"dev": true,
+			"license": "Unlicense"
+		},
+		"node_modules/rollup": {
+			"version": "4.53.3",
+			"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
+			"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@types/estree": "1.0.8"
+			},
+			"bin": {
+				"rollup": "dist/bin/rollup"
+			},
+			"engines": {
+				"node": ">=18.0.0",
+				"npm": ">=8.0.0"
+			},
+			"optionalDependencies": {
+				"@rollup/rollup-android-arm-eabi": "4.53.3",
+				"@rollup/rollup-android-arm64": "4.53.3",
+				"@rollup/rollup-darwin-arm64": "4.53.3",
+				"@rollup/rollup-darwin-x64": "4.53.3",
+				"@rollup/rollup-freebsd-arm64": "4.53.3",
+				"@rollup/rollup-freebsd-x64": "4.53.3",
+				"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
+				"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
+				"@rollup/rollup-linux-arm64-gnu": "4.53.3",
+				"@rollup/rollup-linux-arm64-musl": "4.53.3",
+				"@rollup/rollup-linux-loong64-gnu": "4.53.3",
+				"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
+				"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
+				"@rollup/rollup-linux-riscv64-musl": "4.53.3",
+				"@rollup/rollup-linux-s390x-gnu": "4.53.3",
+				"@rollup/rollup-linux-x64-gnu": "4.53.3",
+				"@rollup/rollup-linux-x64-musl": "4.53.3",
+				"@rollup/rollup-openharmony-arm64": "4.53.3",
+				"@rollup/rollup-win32-arm64-msvc": "4.53.3",
+				"@rollup/rollup-win32-ia32-msvc": "4.53.3",
+				"@rollup/rollup-win32-x64-gnu": "4.53.3",
+				"@rollup/rollup-win32-x64-msvc": "4.53.3",
+				"fsevents": "~2.3.2"
+			}
+		},
+		"node_modules/runed": {
+			"version": "0.25.0",
+			"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
+			"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
+			"funding": [
+				"https://github.com/sponsors/huntabyte",
+				"https://github.com/sponsors/tglide"
+			],
+			"dependencies": {
+				"esm-env": "^1.0.0"
+			},
+			"peerDependencies": {
+				"svelte": "^5.7.0"
+			}
+		},
+		"node_modules/rw": {
+			"version": "1.3.3",
+			"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+			"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+			"dev": true,
+			"license": "BSD-3-Clause"
+		},
+		"node_modules/sade": {
+			"version": "1.8.1",
+			"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+			"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"mri": "^1.1.0"
+			},
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/safer-buffer": {
+			"version": "2.1.2",
+			"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/set-cookie-parser": {
+			"version": "2.7.2",
+			"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+			"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/sirv": {
+			"version": "3.0.2",
+			"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+			"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@polka/url": "^1.0.0-next.24",
+				"mrmime": "^2.0.0",
+				"totalist": "^3.0.0"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/source-map-js": {
+			"version": "1.2.1",
+			"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+			"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+			"dev": true,
+			"license": "BSD-3-Clause",
+			"engines": {
+				"node": ">=0.10.0"
+			}
+		},
+		"node_modules/style-to-object": {
+			"version": "1.0.14",
+			"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+			"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+			"license": "MIT",
+			"dependencies": {
+				"inline-style-parser": "0.2.7"
+			}
+		},
+		"node_modules/svelte": {
+			"version": "5.45.3",
+			"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
+			"integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/remapping": "^2.3.4",
+				"@jridgewell/sourcemap-codec": "^1.5.0",
+				"@sveltejs/acorn-typescript": "^1.0.5",
+				"@types/estree": "^1.0.5",
+				"acorn": "^8.12.1",
+				"aria-query": "^5.3.1",
+				"axobject-query": "^4.1.0",
+				"clsx": "^2.1.1",
+				"devalue": "^5.5.0",
+				"esm-env": "^1.2.1",
+				"esrap": "^2.2.0",
+				"is-reference": "^3.0.3",
+				"locate-character": "^3.0.0",
+				"magic-string": "^0.30.11",
+				"zimmerframe": "^1.1.2"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
+		"node_modules/svelte-check": {
+			"version": "4.3.4",
+			"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.4.tgz",
+			"integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"@jridgewell/trace-mapping": "^0.3.25",
+				"chokidar": "^4.0.1",
+				"fdir": "^6.2.0",
+				"picocolors": "^1.0.0",
+				"sade": "^1.7.4"
+			},
+			"bin": {
+				"svelte-check": "bin/svelte-check"
+			},
+			"engines": {
+				"node": ">= 18.0.0"
+			},
+			"peerDependencies": {
+				"svelte": "^4.0.0 || ^5.0.0-next.0",
+				"typescript": ">=5.0.0"
+			}
+		},
+		"node_modules/svelte-toolbelt": {
+			"version": "0.7.1",
+			"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
+			"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
+			"funding": [
+				"https://github.com/sponsors/huntabyte"
+			],
+			"dependencies": {
+				"clsx": "^2.1.1",
+				"runed": "^0.23.2",
+				"style-to-object": "^1.0.8"
+			},
+			"engines": {
+				"node": ">=18",
+				"pnpm": ">=8.7.0"
+			},
+			"peerDependencies": {
+				"svelte": "^5.0.0"
+			}
+		},
+		"node_modules/svelte-toolbelt/node_modules/runed": {
+			"version": "0.23.4",
+			"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
+			"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
+			"funding": [
+				"https://github.com/sponsors/huntabyte",
+				"https://github.com/sponsors/tglide"
+			],
+			"dependencies": {
+				"esm-env": "^1.0.0"
+			},
+			"peerDependencies": {
+				"svelte": "^5.7.0"
+			}
+		},
+		"node_modules/tailwindcss": {
+			"version": "4.1.17",
+			"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
+			"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/tapable": {
+			"version": "2.3.0",
+			"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+			"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/webpack"
+			}
+		},
+		"node_modules/tinyglobby": {
+			"version": "0.2.15",
+			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+			"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"fdir": "^6.5.0",
+				"picomatch": "^4.0.3"
+			},
+			"engines": {
+				"node": ">=12.0.0"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/SuperchupuDev"
+			}
+		},
+		"node_modules/totalist": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+			"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
+		"node_modules/tw-animate-css": {
+			"version": "1.4.0",
+			"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+			"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+			"dev": true,
+			"license": "MIT",
+			"funding": {
+				"url": "https://github.com/sponsors/Wombosvideo"
+			}
+		},
+		"node_modules/typescript": {
+			"version": "5.9.3",
+			"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+			"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"bin": {
+				"tsc": "bin/tsc",
+				"tsserver": "bin/tsserver"
+			},
+			"engines": {
+				"node": ">=14.17"
+			}
+		},
+		"node_modules/undici-types": {
+			"version": "6.21.0",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+			"dev": true,
+			"license": "MIT"
+		},
+		"node_modules/vite": {
+			"version": "6.4.1",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+			"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+			"dev": true,
+			"license": "MIT",
+			"dependencies": {
+				"esbuild": "^0.25.0",
+				"fdir": "^6.4.4",
+				"picomatch": "^4.0.2",
+				"postcss": "^8.5.3",
+				"rollup": "^4.34.9",
+				"tinyglobby": "^0.2.13"
+			},
+			"bin": {
+				"vite": "bin/vite.js"
+			},
+			"engines": {
+				"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+			},
+			"funding": {
+				"url": "https://github.com/vitejs/vite?sponsor=1"
+			},
+			"optionalDependencies": {
+				"fsevents": "~2.3.3"
+			},
+			"peerDependencies": {
+				"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+				"jiti": ">=1.21.0",
+				"less": "*",
+				"lightningcss": "^1.21.0",
+				"sass": "*",
+				"sass-embedded": "*",
+				"stylus": "*",
+				"sugarss": "*",
+				"terser": "^5.16.0",
+				"tsx": "^4.8.1",
+				"yaml": "^2.4.2"
+			},
+			"peerDependenciesMeta": {
+				"@types/node": {
+					"optional": true
+				},
+				"jiti": {
+					"optional": true
+				},
+				"less": {
+					"optional": true
+				},
+				"lightningcss": {
+					"optional": true
+				},
+				"sass": {
+					"optional": true
+				},
+				"sass-embedded": {
+					"optional": true
+				},
+				"stylus": {
+					"optional": true
+				},
+				"sugarss": {
+					"optional": true
+				},
+				"terser": {
+					"optional": true
+				},
+				"tsx": {
+					"optional": true
+				},
+				"yaml": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/vitefu": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+			"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+			"dev": true,
+			"license": "MIT",
+			"workspaces": [
+				"tests/deps/*",
+				"tests/projects/*",
+				"tests/projects/workspace/packages/*"
+			],
+			"peerDependencies": {
+				"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+			},
+			"peerDependenciesMeta": {
+				"vite": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/zimmerframe": {
+			"version": "1.1.4",
+			"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+			"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+			"license": "MIT"
+		}
+	}
+}
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 00000000..c9c27630
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,33 @@
+{
+	"name": "exo-dashboard",
+	"private": true,
+	"version": "1.0.0",
+	"type": "module",
+	"scripts": {
+		"dev": "vite dev",
+		"build": "vite build",
+		"preview": "vite preview",
+		"prepare": "svelte-kit sync || echo ''",
+		"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
+	},
+	"devDependencies": {
+		"@sveltejs/adapter-static": "^3.0.10",
+		"@sveltejs/kit": "^2.48.4",
+		"@sveltejs/vite-plugin-svelte": "^5.0.0",
+		"@tailwindcss/vite": "^4.0.0",
+		"@types/d3": "^7.4.3",
+		"@types/node": "^22",
+		"d3": "^7.9.0",
+		"svelte": "^5.0.0",
+		"svelte-check": "^4.0.0",
+		"tailwindcss": "^4.0.0",
+		"tw-animate-css": "^1.3.5",
+		"typescript": "^5.0.0",
+		"vite": "^6.0.0"
+	},
+	"dependencies": {
+		"highlight.js": "^11.11.1",
+		"mode-watcher": "^1.1.0"
+	}
+}
+
diff --git a/dashboard/src/app.css b/dashboard/src/app.css
new file mode 100644
index 00000000..fc532578
--- /dev/null
+++ b/dashboard/src/app.css
@@ -0,0 +1,322 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+	/* EXO Brand Colors - Command Center Theme (neutral dark greys) */
+	--exo-black: oklch(0.12 0 0);
+	--exo-dark-gray: oklch(0.16 0 0);
+	--exo-medium-gray: oklch(0.22 0 0);
+	--exo-light-gray: oklch(0.6 0 0);
+	--exo-yellow: oklch(0.85 0.18 85);
+	--exo-yellow-darker: oklch(0.7 0.16 85);
+	--exo-yellow-glow: oklch(0.9 0.2 85);
+	
+	/* Gotham-inspired accent colors */
+	--exo-grid: oklch(0.25 0 0);
+	--exo-scanline: oklch(0.15 0 0);
+	--exo-glow-yellow: 0 0 20px oklch(0.85 0.18 85 / 0.3);
+	--exo-glow-yellow-strong: 0 0 40px oklch(0.85 0.18 85 / 0.5);
+	
+	/* Theme Variables */
+	--radius: 0.375rem;
+	--background: var(--exo-black);
+	--foreground: oklch(0.9 0 0);
+	--card: var(--exo-dark-gray);
+	--card-foreground: oklch(0.9 0 0);
+	--popover: var(--exo-dark-gray);
+	--popover-foreground: oklch(0.9 0 0);
+	--primary: var(--exo-yellow);
+	--primary-foreground: var(--exo-black);
+	--secondary: var(--exo-medium-gray);
+	--secondary-foreground: oklch(0.9 0 0);
+	--muted: var(--exo-medium-gray);
+	--muted-foreground: var(--exo-light-gray);
+	--accent: var(--exo-medium-gray);
+	--accent-foreground: oklch(0.9 0 0);
+	--destructive: oklch(0.6 0.25 25);
+	--border: oklch(0.22 0 0);
+	--input: oklch(0.22 0 0);
+	--ring: var(--exo-yellow);
+}
+
+@theme inline {
+	--radius-sm: calc(var(--radius) - 2px);
+	--radius-md: var(--radius);
+	--radius-lg: calc(var(--radius) + 2px);
+	--radius-xl: calc(var(--radius) + 4px);
+	--color-background: var(--background);
+	--color-foreground: var(--foreground);
+	--color-card: var(--card);
+	--color-card-foreground: var(--card-foreground);
+	--color-popover: var(--popover);
+	--color-popover-foreground: var(--popover-foreground);
+	--color-primary: var(--primary);
+	--color-primary-foreground: var(--primary-foreground);
+	--color-secondary: var(--secondary);
+	--color-secondary-foreground: var(--secondary-foreground);
+	--color-muted: var(--muted);
+	--color-muted-foreground: var(--muted-foreground);
+	--color-accent: var(--accent);
+	--color-accent-foreground: var(--accent-foreground);
+	--color-destructive: var(--destructive);
+	--color-border: var(--border);
+	--color-input: var(--input);
+	--color-ring: var(--ring);
+	
+	/* Custom EXO colors */
+	--color-exo-yellow: var(--exo-yellow);
+	--color-exo-yellow-darker: var(--exo-yellow-darker);
+	--color-exo-black: var(--exo-black);
+	--color-exo-dark-gray: var(--exo-dark-gray);
+	--color-exo-medium-gray: var(--exo-medium-gray);
+	--color-exo-light-gray: var(--exo-light-gray);
+}
+
+@layer base {
+	* {
+		@apply border-border outline-ring/50;
+	}
+	html, body {
+		@apply bg-background text-foreground;
+		font-family: 'SF Mono', 'Fira Code', 'Monaco', 'Consolas', 'Liberation Mono', monospace;
+		letter-spacing: 0.02em;
+	}
+}
+
+@layer utilities {
+	.scrollbar-hide {
+		&::-webkit-scrollbar {
+			display: none;
+		}
+		-ms-overflow-style: none;
+		scrollbar-width: none;
+	}
+	
+	/* CRT Scanline effect */
+	.scanlines {
+		position: relative;
+		&::before {
+			content: '';
+			position: absolute;
+			inset: 0;
+			background: repeating-linear-gradient(
+				0deg,
+				transparent,
+				transparent 2px,
+				oklch(0 0 0 / 0.03) 2px,
+				oklch(0 0 0 / 0.03) 4px
+			);
+			pointer-events: none;
+			z-index: 100;
+		}
+	}
+	
+	/* Command panel styling */
+	.command-panel {
+		background: linear-gradient(
+			180deg,
+			oklch(0.16 0 0 / 0.95) 0%,
+			oklch(0.12 0 0 / 0.98) 100%
+		);
+		border: 1px solid oklch(0.25 0 0);
+		box-shadow: 
+			inset 0 1px 0 oklch(1 0 0 / 0.03),
+			0 4px 20px oklch(0 0 0 / 0.5);
+	}
+	
+	/* Glow text */
+	.glow-text {
+		text-shadow: 
+			0 0 10px oklch(0.85 0.18 85 / 0.5),
+			0 0 20px oklch(0.85 0.18 85 / 0.3),
+			0 0 40px oklch(0.85 0.18 85 / 0.1);
+	}
+	
+	/* Status indicator pulse */
+	.status-pulse {
+		animation: statusPulse 2s ease-in-out infinite;
+	}
+	
+	/* Grid background */
+	.grid-bg {
+		background-image: 
+			linear-gradient(oklch(0.2 0 0 / 0.3) 1px, transparent 1px),
+			linear-gradient(90deg, oklch(0.2 0 0 / 0.3) 1px, transparent 1px);
+		background-size: 40px 40px;
+	}
+}
+
+/* Animations */
+@keyframes flowAnimation {
+	from {
+		stroke-dashoffset: 0;
+	}
+	to {
+		stroke-dashoffset: -16;
+	}
+}
+
+@keyframes statusPulse {
+	0%, 100% {
+		opacity: 1;
+	}
+	50% {
+		opacity: 0.5;
+	}
+}
+
+@keyframes radarSweep {
+	from {
+		transform: rotate(0deg);
+	}
+	to {
+		transform: rotate(360deg);
+	}
+}
+
+@keyframes glowPulse {
+	0%, 100% {
+		box-shadow: 0 0 5px oklch(0.85 0.18 85 / 0.3), 0 0 10px oklch(0.85 0.18 85 / 0.1);
+	}
+	50% {
+		box-shadow: 0 0 15px oklch(0.85 0.18 85 / 0.5), 0 0 30px oklch(0.85 0.18 85 / 0.2);
+	}
+}
+
+@keyframes dataPulse {
+	0%, 100% {
+		opacity: 0.6;
+	}
+	50% {
+		opacity: 1;
+	}
+}
+
+.graph-link {
+	stroke: oklch(0.85 0.18 85 / 0.4);
+	stroke-width: 1.5px;
+	stroke-dasharray: 8, 8;
+	animation: flowAnimation 1s linear infinite;
+	filter: drop-shadow(0 0 3px oklch(0.85 0.18 85 / 0.5));
+}
+
+.graph-link-active {
+	stroke: oklch(0.85 0.18 85 / 0.8);
+	stroke-width: 2px;
+	filter: drop-shadow(0 0 6px oklch(0.85 0.18 85 / 0.8));
+}
+
+/* CRT Screen effect for topology */
+.crt-screen {
+	position: relative;
+	border-radius: 50%;
+	background: radial-gradient(
+		ellipse at center,
+		oklch(0.16 0 0) 0%,
+		oklch(0.12 0 0) 50%,
+		oklch(0.09 0 0) 100%
+	);
+	box-shadow:
+		inset 0 0 100px oklch(0 0 0 / 0.5),
+		0 0 50px oklch(0.85 0.18 85 / 0.1);
+}
+
+/* Data readout styling */
+.data-readout {
+	font-family: 'SF Mono', 'Fira Code', monospace;
+	font-size: 11px;
+	letter-spacing: 0.05em;
+	text-transform: uppercase;
+}
+
+/* Terminal cursor blink */
+.cursor-blink {
+	animation: cursorBlink 1s step-end infinite;
+}
+
+@keyframes cursorBlink {
+	0%, 100% { opacity: 1; }
+	50% { opacity: 0; }
+}
+
+/* Custom scrollbar for command center */
+::-webkit-scrollbar {
+	width: 6px;
+	height: 6px;
+}
+
+::-webkit-scrollbar-track {
+	background: oklch(0.1 0 0);
+}
+
+::-webkit-scrollbar-thumb {
+	background: oklch(0.3 0 0);
+	border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+	background: oklch(0.85 0.18 85 / 0.5);
+}
+
+/* Remove focus outline/border for inputs */
+input:focus, textarea:focus {
+	outline: none;
+	box-shadow: none;
+}
+
+/* Shooting Stars Animation */
+.shooting-stars {
+	position: fixed;
+	inset: 0;
+	overflow: hidden;
+	pointer-events: none;
+	z-index: 0;
+}
+
+.shooting-star {
+	position: absolute;
+	width: 3px;
+	height: 3px;
+	background: oklch(0.85 0.18 85 / 1);
+	border-radius: 50%;
+	box-shadow: 0 0 6px oklch(0.85 0.18 85 / 0.8);
+	animation: shootingStar var(--duration, 3s) linear infinite;
+	animation-delay: var(--delay, 0s);
+	opacity: 0;
+}
+
+.shooting-star::before {
+	content: '';
+	position: absolute;
+	width: 80px;
+	height: 2px;
+	background: linear-gradient(90deg, oklch(0.85 0.18 85 / 0), oklch(0.85 0.18 85 / 0.6));
+	transform: rotate(45deg);
+	transform-origin: right center;
+	top: 0;
+	right: 2px;
+}
+
+@keyframes shootingStar {
+	0% {
+		opacity: 0;
+		transform: translate(0, 0);
+	}
+	0.5% {
+		opacity: 1;
+	}
+	2.5% {
+		opacity: 0.8;
+		transform: translate(300px, 300px);
+	}
+	3.5% {
+		opacity: 0;
+		transform: translate(400px, 400px);
+	}
+	100% {
+		opacity: 0;
+		transform: translate(400px, 400px);
+	}
+}
diff --git a/dashboard/src/app.d.ts b/dashboard/src/app.d.ts
new file mode 100644
index 00000000..b111beb0
--- /dev/null
+++ b/dashboard/src/app.d.ts
@@ -0,0 +1,14 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+	namespace App {
+		// interface Error {}
+		// interface Locals {}
+		// interface PageData {}
+		// interface PageState {}
+		// interface Platform {}
+	}
+}
+
+export {};
+
diff --git a/dashboard/src/app.html b/dashboard/src/app.html
new file mode 100644
index 00000000..a974a968
--- /dev/null
+++ b/dashboard/src/app.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>EXO</title>
+		%sveltekit.head%
+	</head>
+	<body data-sveltekit-preload-data="hover">
+		<div style="display: contents">%sveltekit.body%</div>
+	</body>
+</html>
+
diff --git a/dashboard/src/lib/components/ChatAttachments.svelte b/dashboard/src/lib/components/ChatAttachments.svelte
new file mode 100644
index 00000000..f56e23e3
--- /dev/null
+++ b/dashboard/src/lib/components/ChatAttachments.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+	import type { ChatUploadedFile } from '$lib/types/files';
+	import { formatFileSize, getFileCategory } from '$lib/types/files';
+
+	interface Props {
+		files: ChatUploadedFile[];
+		readonly?: boolean;
+		onRemove?: (fileId: string) => void;
+	}
+
+	let { files, readonly = false, onRemove }: Props = $props();
+
+	function getFileIcon(file: ChatUploadedFile): string {
+		const category = getFileCategory(file.type, file.name);
+		switch (category) {
+			case 'image': return '🖼';
+			case 'text': return '📄';
+			case 'pdf': return '📑';
+			case 'audio': return '🎵';
+			default: return '📎';
+		}
+	}
+
+	function truncateName(name: string, maxLen: number = 20): string {
+		if (name.length <= maxLen) return name;
+		const ext = name.slice(name.lastIndexOf('.'));
+		const base = name.slice(0, name.lastIndexOf('.'));
+		const available = maxLen - ext.length - 3;
+		return base.slice(0, available) + '...' + ext;
+	}
+</script>
+
+{#if files.length > 0}
+	<div class="flex flex-wrap gap-2 mb-3 px-1">
+		{#each files as file (file.id)}
+			<div class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]">
+				<!-- File preview or icon -->
+				{#if file.preview && getFileCategory(file.type, file.name) === 'image'}
+					<img 
+						src={file.preview} 
+						alt={file.name}
+						class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
+					/>
+				{:else}
+					<span class="text-base">{getFileIcon(file)}</span>
+				{/if}
+				
+				<!-- File info -->
+				<div class="flex flex-col min-w-0">
+					<span class="text-exo-yellow truncate max-w-[120px]" title={file.name}>
+						{truncateName(file.name)}
+					</span>
+					<span class="text-exo-light-gray text-xs">
+						{formatFileSize(file.size)}
+					</span>
+				</div>
+				
+				<!-- Remove button -->
+				{#if !readonly && onRemove}
+					<button
+						type="button"
+						onclick={() => onRemove?.(file.id)}
+						class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+						title="Remove file"
+					>
+						<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+							<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+						</svg>
+					</button>
+				{/if}
+			</div>
+		{/each}
+	</div>
+{/if}
+
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
new file mode 100644
index 00000000..95d023c3
--- /dev/null
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -0,0 +1,398 @@
+<script lang="ts">
+	import { isLoading, sendMessage, selectedChatModel, setSelectedChatModel, instances, ttftMs, tps, totalTokens } from '$lib/stores/app.svelte';
+	import ChatAttachments from './ChatAttachments.svelte';
+	import type { ChatUploadedFile } from '$lib/types/files';
+	import { processUploadedFiles, getAcceptString } from '$lib/types/files';
+
+	interface Props {
+		class?: string;
+		placeholder?: string;
+		showHelperText?: boolean;
+		autofocus?: boolean;
+		showModelSelector?: boolean;
+	}
+
+	let { 
+		class: className = '', 
+		placeholder = 'Ask anything',
+		showHelperText = false,
+		autofocus = true,
+		showModelSelector = false
+	}: Props = $props();
+
+	let message = $state('');
+	let textareaRef: HTMLTextAreaElement | undefined = $state();
+	let fileInputRef: HTMLInputElement | undefined = $state();
+	let uploadedFiles = $state<ChatUploadedFile[]>([]);
+	let isDragOver = $state(false);
+	let loading = $derived(isLoading());
+	const currentModel = $derived(selectedChatModel());
+	const instanceData = $derived(instances());
+	const currentTtft = $derived(ttftMs());
+	const currentTps = $derived(tps());
+	const currentTokens = $derived(totalTokens());
+	
+	// Custom dropdown state
+	let isModelDropdownOpen = $state(false);
+	let dropdownButtonRef: HTMLButtonElement | undefined = $state();
+	let dropdownPosition = $derived(() => {
+		if (!dropdownButtonRef || !isModelDropdownOpen) return { top: 0, left: 0, width: 0 };
+		const rect = dropdownButtonRef.getBoundingClientRect();
+		return {
+			top: rect.top,
+			left: rect.left,
+			width: rect.width
+		};
+	});
+
+	// Accept all supported file types
+	const acceptString = getAcceptString(['image', 'text', 'pdf']);
+
+	// Extract available models from running instances
+	const availableModels = $derived(() => {
+		const models: Array<{id: string, label: string}> = [];
+		for (const [, instance] of Object.entries(instanceData)) {
+			const modelId = getInstanceModelId(instance);
+			if (modelId && modelId !== 'Unknown' && !models.some(m => m.id === modelId)) {
+				models.push({ id: modelId, label: modelId.split('/').pop() || modelId });
+			}
+		}
+		return models;
+	});
+
+	// Auto-select the first available model if none is selected
+	$effect(() => {
+		const models = availableModels();
+		if (models.length > 0 && !currentModel) {
+			setSelectedChatModel(models[0].id);
+		}
+	});
+
+	function getInstanceModelId(instanceWrapped: unknown): string {
+		if (!instanceWrapped || typeof instanceWrapped !== 'object') return '';
+		const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+		if (keys.length === 1) {
+			const instance = (instanceWrapped as Record<string, unknown>)[keys[0]] as { shardAssignments?: { modelId?: string } };
+			return instance?.shardAssignments?.modelId || '';
+		}
+		return '';
+	}
+
+	async function handleFiles(files: File[]) {
+		if (files.length === 0) return;
+		const processed = await processUploadedFiles(files);
+		uploadedFiles = [...uploadedFiles, ...processed];
+	}
+
+	function handleFileInputChange(event: Event) {
+		const input = event.target as HTMLInputElement;
+		if (input.files && input.files.length > 0) {
+			handleFiles(Array.from(input.files));
+			input.value = ''; // Reset for next selection
+		}
+	}
+
+	function handleFileRemove(fileId: string) {
+		uploadedFiles = uploadedFiles.filter(f => f.id !== fileId);
+	}
+
+	function handlePaste(event: ClipboardEvent) {
+		if (!event.clipboardData) return;
+		
+		const files = Array.from(event.clipboardData.items)
+			.filter(item => item.kind === 'file')
+			.map(item => item.getAsFile())
+			.filter((file): file is File => file !== null);
+		
+		if (files.length > 0) {
+			event.preventDefault();
+			handleFiles(files);
+			return;
+		}
+		
+		// Handle long text paste as file
+		const text = event.clipboardData.getData('text/plain');
+		if (text.length > 2500) {
+			event.preventDefault();
+			const textFile = new File([text], 'pasted-text.txt', { type: 'text/plain' });
+			handleFiles([textFile]);
+		}
+	}
+
+	function handleDragOver(event: DragEvent) {
+		event.preventDefault();
+		isDragOver = true;
+	}
+
+	function handleDragLeave(event: DragEvent) {
+		event.preventDefault();
+		isDragOver = false;
+	}
+
+	function handleDrop(event: DragEvent) {
+		event.preventDefault();
+		isDragOver = false;
+		
+		if (event.dataTransfer?.files) {
+			handleFiles(Array.from(event.dataTransfer.files));
+		}
+	}
+
+	function handleKeydown(event: KeyboardEvent) {
+		if (event.key === 'Enter' && !event.shiftKey) {
+			event.preventDefault();
+			handleSubmit();
+		}
+	}
+
+	function handleSubmit() {
+		if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
+		
+		const content = message.trim();
+		const files = [...uploadedFiles];
+		
+		message = '';
+		uploadedFiles = [];
+		resetTextareaHeight();
+		
+		sendMessage(content, files);
+		
+		// Refocus the textarea after sending
+		setTimeout(() => textareaRef?.focus(), 10);
+	}
+
+	function handleInput() {
+		if (!textareaRef) return;
+		textareaRef.style.height = 'auto';
+		textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + 'px';
+	}
+
+	function resetTextareaHeight() {
+		if (textareaRef) {
+			textareaRef.style.height = 'auto';
+		}
+	}
+
+	function openFilePicker() {
+		fileInputRef?.click();
+	}
+
+	// Track previous loading state to detect when loading completes
+	let wasLoading = $state(false);
+	
+	$effect(() => {
+		if (autofocus && textareaRef) {
+			setTimeout(() => textareaRef?.focus(), 10);
+		}
+	});
+	
+	// Refocus after loading completes (AI response finished)
+	$effect(() => {
+		if (wasLoading && !loading && textareaRef) {
+			setTimeout(() => textareaRef?.focus(), 50);
+		}
+		wasLoading = loading;
+	});
+
+	const canSend = $derived(message.trim().length > 0 || uploadedFiles.length > 0);
+</script>
+
+<!-- Hidden file input -->
+<input
+	bind:this={fileInputRef}
+	type="file"
+	accept={acceptString}
+	multiple
+	class="hidden"
+	onchange={handleFileInputChange}
+/>
+
+<form 
+	onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} 
+	class="w-full {className}"
+	ondragover={handleDragOver}
+	ondragleave={handleDragLeave}
+	ondrop={handleDrop}
+>
+	<div 
+		class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver ? 'ring-2 ring-exo-yellow ring-opacity-50' : ''}"
+	>
+		<!-- Top accent line -->
+		<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"></div>
+		
+		<!-- Drag overlay -->
+		{#if isDragOver}
+			<div class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center">
+				<div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
+					DROP FILES HERE
+				</div>
+			</div>
+		{/if}
+		
+		<!-- Model selector (when enabled) -->
+		{#if showModelSelector && availableModels().length > 0}
+			<div class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30">
+				<div class="flex items-center gap-2 flex-1">
+					<span class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0">MODEL:</span>
+					<!-- Custom dropdown -->
+					<div class="relative flex-1 max-w-xs">
+						<button
+							bind:this={dropdownButtonRef}
+							type="button"
+							onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
+							class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
+						>
+							{#if availableModels().find(m => m.id === currentModel)}
+								<span class="text-exo-yellow truncate">{availableModels().find(m => m.id === currentModel)?.label}</span>
+							{:else if availableModels().length > 0}
+								<span class="text-exo-yellow truncate">{availableModels()[0].label}</span>
+							{:else}
+								<span class="text-exo-light-gray/50">— SELECT MODEL —</span>
+							{/if}
+						</button>
+						<div class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
+							<svg class="w-3 h-3 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+							</svg>
+						</div>
+					</div>
+					
+					{#if isModelDropdownOpen}
+						<!-- Backdrop to close dropdown -->
+						<button 
+							type="button"
+							class="fixed inset-0 z-[9998] cursor-default" 
+							onclick={() => isModelDropdownOpen = false}
+							aria-label="Close dropdown"
+						></button>
+						
+						<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+						<div 
+							class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
+							style="bottom: calc(100vh - {dropdownPosition().top}px + 4px); left: {dropdownPosition().left}px; width: {dropdownPosition().width}px;"
+						>
+							<div class="py-1">
+								{#each availableModels() as model}
+									<button
+										type="button"
+										onclick={() => {
+											setSelectedChatModel(model.id);
+											isModelDropdownOpen = false;
+										}}
+										class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
+											currentModel === model.id 
+											? 'bg-transparent text-exo-yellow' 
+											: 'text-exo-light-gray hover:text-exo-yellow'
+										}"
+									>
+										{#if currentModel === model.id}
+											<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
+												<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
+											</svg>
+										{:else}
+											<span class="w-3"></span>
+										{/if}
+										<span class="truncate">{model.label}</span>
+									</button>
+								{/each}
+							</div>
+						</div>
+					{/if}
+				</div>
+				<!-- Performance stats -->
+				{#if currentTtft !== null || currentTps !== null}
+					<div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
+						{#if currentTtft !== null}
+							<span class="text-exo-light-gray">
+								<span class="text-white/70">TTFT</span> <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
+							</span>
+						{/if}
+						{#if currentTps !== null}
+							<span class="text-exo-light-gray">
+								<span class="text-white/70">TPS</span> <span class="text-exo-yellow">{currentTps.toFixed(1)}</span> <span class="text-white/60">tok/s</span>
+								<span class="text-white/50">({(1000 / currentTps).toFixed(1)} ms/tok)</span>
+							</span>
+						{/if}
+					</div>
+				{/if}
+			</div>
+		{/if}
+		
+		<!-- Attached files preview -->
+		{#if uploadedFiles.length > 0}
+			<div class="px-3 pt-3">
+				<ChatAttachments 
+					files={uploadedFiles} 
+					onRemove={handleFileRemove}
+				/>
+			</div>
+		{/if}
+		
+		<!-- Input area -->
+		<div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
+			<!-- Attach file button -->
+			<button
+				type="button"
+				onclick={openFilePicker}
+				disabled={loading}
+				class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
+				title="Attach file"
+			>
+				<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
+				</svg>
+			</button>
+			
+			<!-- Terminal prompt -->
+			<span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7">▶</span>
+			
+			<textarea
+				bind:this={textareaRef}
+				bind:value={message}
+				onkeydown={handleKeydown}
+				oninput={handleInput}
+				onpaste={handlePaste}
+				{placeholder}
+				disabled={loading}
+				rows={1}
+				class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
+				style="min-height: 28px; max-height: 150px;"
+			></textarea>
+			
+			<button
+				type="submit"
+				disabled={!canSend || loading}
+				class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
+					{!canSend || loading 
+						? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed' 
+						: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
+				aria-label="Send message"
+			>
+				{#if loading}
+					<span class="inline-flex items-center gap-1 sm:gap-2">
+						<span class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"></span>
+						<span class="hidden sm:inline">PROCESSING</span>
+						<span class="sm:hidden">...</span>
+					</span>
+				{:else}
+					SEND
+				{/if}
+			</button>
+		</div>
+		
+		<!-- Bottom accent line -->
+		<div class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"></div>
+	</div>
+	
+	{#if showHelperText}
+		<p class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">
+			<kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">ENTER</kbd>
+			<span class="mx-0.5 sm:mx-1">TO SEND</span>
+			<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+			<kbd class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50">SHIFT+ENTER</kbd>
+			<span class="mx-0.5 sm:mx-1">NEW LINE</span>
+			<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+			<span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
+		</p>
+	{/if}
+</form>
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
new file mode 100644
index 00000000..baaf43f7
--- /dev/null
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -0,0 +1,462 @@
+<script lang="ts">
+	import { 
+		messages, 
+		currentResponse, 
+		isLoading,
+		deleteMessage,
+		editAndRegenerate,
+		regenerateLastResponse
+	} from '$lib/stores/app.svelte';
+	import type { MessageAttachment } from '$lib/stores/app.svelte';
+import { tick, onDestroy } from 'svelte';
+
+interface Props {
+	class?: string;
+	scrollParent?: HTMLElement | null;
+}
+
+let { class: className = '', scrollParent = null }: Props = $props();
+
+	const messageList = $derived(messages());
+	const response = $derived(currentResponse());
+	const loading = $derived(isLoading());
+
+// Ref for scroll anchor at bottom
+let scrollAnchorRef: HTMLDivElement | undefined = $state();
+
+// Scroll management
+const SCROLL_BOTTOM_THRESHOLD = 120;
+let autoScrollEnabled = true;
+let currentScrollEl: HTMLElement | null = null;
+
+function resolveScrollElement(): HTMLElement | null {
+	if (scrollParent) return scrollParent;
+	let node: HTMLElement | null = scrollAnchorRef?.parentElement as HTMLElement | null;
+	while (node) {
+		const isScrollable = node.scrollHeight > node.clientHeight + 1;
+		if (isScrollable) return node;
+		node = node.parentElement;
+	}
+	return null;
+}
+
+function handleScroll() {
+	if (!currentScrollEl) return;
+	const distanceFromBottom = currentScrollEl.scrollHeight - currentScrollEl.scrollTop - currentScrollEl.clientHeight;
+	const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD;
+	autoScrollEnabled = isNearBottom;
+}
+
+function attachScrollListener() {
+	const nextEl = resolveScrollElement();
+	if (currentScrollEl === nextEl) return;
+	if (currentScrollEl) {
+		currentScrollEl.removeEventListener('scroll', handleScroll);
+	}
+	currentScrollEl = nextEl;
+	if (currentScrollEl) {
+		currentScrollEl.addEventListener('scroll', handleScroll);
+		// Initialize state based on current position
+		handleScroll();
+	}
+}
+
+onDestroy(() => {
+	if (currentScrollEl) {
+		currentScrollEl.removeEventListener('scroll', handleScroll);
+	}
+});
+
+$effect(() => {
+	// Re-evaluate scroll container if prop changes or after mount
+	scrollParent;
+	attachScrollListener();
+});
+
+// Auto-scroll to bottom when messages change or response updates, but only if user is near bottom
+$effect(() => {
+	// Track these values to trigger effect
+	const _ = messageList.length;
+	const __ = response;
+	const ___ = loading;
+	
+	tick().then(() => {
+		const el = currentScrollEl ?? resolveScrollElement();
+		if (!el || !scrollAnchorRef) return;
+		const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+		const isNearBottom = distanceFromBottom < SCROLL_BOTTOM_THRESHOLD;
+		if (autoScrollEnabled || isNearBottom) {
+			scrollAnchorRef.scrollIntoView({ behavior: 'smooth', block: 'end' });
+			autoScrollEnabled = true;
+		}
+	});
+});
+
+	// Edit state
+	let editingMessageId = $state<string | null>(null);
+	let editContent = $state('');
+	let editTextareaRef: HTMLTextAreaElement | undefined = $state();
+
+	// Delete confirmation state
+	let deleteConfirmId = $state<string | null>(null);
+
+// Copied state for feedback
+let copiedMessageId = $state<string | null>(null);
+let expandedThinkingMessageIds = $state<Set<string>>(new Set());
+
+	function formatTimestamp(timestamp: number): string {
+		return new Date(timestamp).toLocaleTimeString('en-US', { 
+			hour12: false,
+			hour: '2-digit', 
+			minute: '2-digit',
+			second: '2-digit'
+		});
+	}
+
+	function getAttachmentIcon(attachment: MessageAttachment): string {
+		switch (attachment.type) {
+			case 'image': return '🖼';
+			case 'text': return '📄';
+			default: return '📎';
+		}
+	}
+
+	function truncateName(name: string, maxLen: number = 25): string {
+		if (name.length <= maxLen) return name;
+		const ext = name.slice(name.lastIndexOf('.'));
+		const base = name.slice(0, name.lastIndexOf('.'));
+		const available = maxLen - ext.length - 3;
+		return base.slice(0, available) + '...' + ext;
+	}
+
+	async function handleCopy(content: string, messageId: string) {
+		try {
+			await navigator.clipboard.writeText(content);
+			copiedMessageId = messageId;
+			setTimeout(() => {
+				copiedMessageId = null;
+			}, 2000);
+		} catch (error) {
+			console.error('Failed to copy:', error);
+		}
+	}
+
+function toggleThinkingVisibility(messageId: string) {
+	const next = new Set(expandedThinkingMessageIds);
+	if (next.has(messageId)) {
+		next.delete(messageId);
+	} else {
+		next.add(messageId);
+	}
+	expandedThinkingMessageIds = next;
+}
+
+function isThinkingExpanded(messageId: string): boolean {
+	return expandedThinkingMessageIds.has(messageId);
+}
+
+	function handleStartEdit(messageId: string, content: string) {
+		editingMessageId = messageId;
+		editContent = content;
+		setTimeout(() => {
+			if (editTextareaRef) {
+				editTextareaRef.focus();
+				editTextareaRef.setSelectionRange(editTextareaRef.value.length, editTextareaRef.value.length);
+				// Auto-resize
+				editTextareaRef.style.height = 'auto';
+				editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
+			}
+		}, 10);
+	}
+
+	function handleCancelEdit() {
+		editingMessageId = null;
+		editContent = '';
+	}
+
+	function handleSaveEdit() {
+		if (editingMessageId && editContent.trim()) {
+			editAndRegenerate(editingMessageId, editContent.trim());
+		}
+		editingMessageId = null;
+		editContent = '';
+	}
+
+	function handleEditKeydown(event: KeyboardEvent) {
+		if (event.key === 'Enter' && !event.shiftKey) {
+			event.preventDefault();
+			handleSaveEdit();
+		} else if (event.key === 'Escape') {
+			handleCancelEdit();
+		}
+	}
+
+	function handleEditInput() {
+		if (editTextareaRef) {
+			editTextareaRef.style.height = 'auto';
+			editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
+		}
+	}
+
+	function handleDeleteClick(messageId: string) {
+		deleteConfirmId = messageId;
+	}
+
+	function handleConfirmDelete() {
+		if (deleteConfirmId) {
+			deleteMessage(deleteConfirmId);
+			deleteConfirmId = null;
+		}
+	}
+
+	function handleCancelDelete() {
+		deleteConfirmId = null;
+	}
+
+	function handleRegenerate() {
+		regenerateLastResponse();
+	}
+
+	// Check if a message is the last assistant message
+	function isLastAssistantMessage(messageId: string): boolean {
+		for (let i = messageList.length - 1; i >= 0; i--) {
+			if (messageList[i].role === 'assistant') {
+				return messageList[i].id === messageId;
+			}
+		}
+		return false;
+	}
+</script>
+
+<div class="flex flex-col gap-4 sm:gap-6 {className}">
+	{#each messageList as message (message.id)}
+		<div class="group flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
+			<div class="{message.role === 'user' ? 'max-w-[85%] sm:max-w-[70%] flex flex-col items-end' : 'max-w-[95%] sm:max-w-[85%]'}">
+				{#if message.role === 'assistant'}
+					<!-- Assistant message header -->
+					<div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+						<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"></div>
+						<span class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium">EXO</span>
+						<span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
+						{#if message.ttftMs || message.tps}
+							<span class="text-xs text-exo-light-gray/80 font-mono ml-2">
+								{#if message.ttftMs}<span class="text-exo-light-gray/50">TTFT</span> {message.ttftMs.toFixed(0)}ms{/if}{#if message.ttftMs && message.tps}<span class="text-exo-light-gray/30 mx-1">•</span>{/if}{#if message.tps}{message.tps.toFixed(1)} <span class="text-exo-light-gray/50">tok/s</span>{/if}
+							</span>
+						{/if}
+					</div>
+				{:else}
+					<!-- User message header -->
+					<div class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+						<span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
+						<span class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">QUERY</span>
+						<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"></div>
+					</div>
+				{/if}
+				
+				{#if deleteConfirmId === message.id}
+					<!-- Delete confirmation -->
+					<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
+						<p class="text-xs text-red-400 mb-3">Delete this message{message.role === 'user' ? ' and all responses after it' : ''}?</p>
+						<div class="flex gap-2 justify-end">
+						<button
+							onclick={handleCancelDelete}
+							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+						>
+							CANCEL
+						</button>
+						<button
+							onclick={handleConfirmDelete}
+							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+						>
+								DELETE
+							</button>
+						</div>
+					</div>
+				{:else if editingMessageId === message.id}
+					<!-- Edit mode -->
+					<div class="command-panel rounded-lg p-3">
+						<textarea
+							bind:this={editTextareaRef}
+							bind:value={editContent}
+							onkeydown={handleEditKeydown}
+							oninput={handleEditInput}
+							class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
+							style="min-height: 60px; max-height: 200px;"
+						></textarea>
+						<div class="flex gap-2 justify-end mt-2">
+						<button
+							onclick={handleCancelEdit}
+							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+						>
+							CANCEL
+						</button>
+						<button
+							onclick={handleSaveEdit}
+							disabled={!editContent.trim()}
+							class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
+						>
+								<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
+								</svg>
+								SEND
+							</button>
+						</div>
+					</div>
+				{:else}
+					<div class="{message.role === 'user' 
+						? 'command-panel rounded-lg rounded-tr-sm inline-block' 
+						: 'command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 inline-block'}">
+						
+						{#if message.role === 'user'}
+							<!-- User message styling -->
+							<div class="px-4 py-3">
+								<!-- Attachments -->
+								{#if message.attachments && message.attachments.length > 0}
+									<div class="flex flex-wrap gap-2 mb-3">
+										{#each message.attachments as attachment}
+											<div class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono">
+												{#if attachment.type === 'image' && attachment.preview}
+													<img 
+														src={attachment.preview} 
+														alt={attachment.name}
+														class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
+													/>
+												{:else}
+													<span>{getAttachmentIcon(attachment)}</span>
+												{/if}
+												<span class="text-exo-yellow" title={attachment.name}>{truncateName(attachment.name)}</span>
+											</div>
+										{/each}
+									</div>
+								{/if}
+								
+								{#if message.content}
+									<div class="text-sm text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
+										{message.content}
+									</div>
+								{/if}
+							</div>
+						{:else}
+							<!-- Assistant message styling -->
+							<div class="p-3 sm:p-4">
+								{#if message.thinking && message.thinking.trim().length > 0}
+									<div class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40">
+										<button
+											type="button"
+											class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
+											onclick={() => toggleThinkingVisibility(message.id)}
+											aria-expanded={isThinkingExpanded(message.id)}
+											aria-controls={`thinking-panel-${message.id}`}
+										>
+											<span class="flex items-center gap-2 tracking-[0.25em]">
+												<svg
+													class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? 'rotate-90' : ''}`}
+													fill="none"
+													viewBox="0 0 24 24"
+													stroke="currentColor"
+													aria-hidden="true"
+												>
+													<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
+												</svg>
+												<span>Thinking...</span>
+											</span>
+											<span class="text-[10px] tracking-[0.2em] text-exo-light-gray/60">
+												{isThinkingExpanded(message.id) ? 'HIDE' : 'SHOW'}
+											</span>
+										</button>
+										{#if isThinkingExpanded(message.id)}
+											<div
+												id={`thinking-panel-${message.id}`}
+												class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
+											>
+												{message.thinking.trim()}
+											</div>
+										{/if}
+									</div>
+								{/if}
+								<div class="text-sm text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
+									{message.content || (loading ? response : '')}
+									{#if loading && !message.content}
+										<span class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"></span>
+									{/if}
+								</div>
+							</div>
+						{/if}
+					</div>
+					
+					<!-- Action buttons -->
+					<div class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role === 'user' ? 'justify-end' : 'justify-start'}">
+						<!-- Copy button -->
+						<button
+							onclick={() => handleCopy(message.content, message.id)}
+							class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+							title="Copy message"
+						>
+							{#if copiedMessageId === message.id}
+								<svg class="w-3.5 h-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
+								</svg>
+							{:else}
+								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
+								</svg>
+							{/if}
+						</button>
+						
+						<!-- Edit button (user messages only) -->
+						{#if message.role === 'user'}
+							<button
+								onclick={() => handleStartEdit(message.id, message.content)}
+								class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+								title="Edit message"
+							>
+								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+								</svg>
+							</button>
+						{/if}
+						
+						<!-- Regenerate button (last assistant message only) -->
+						{#if message.role === 'assistant' && isLastAssistantMessage(message.id) && !loading}
+							<button
+								onclick={handleRegenerate}
+								class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+								title="Regenerate response"
+							>
+								<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+								</svg>
+							</button>
+						{/if}
+						
+						<!-- Delete button -->
+						<button
+							onclick={() => handleDeleteClick(message.id)}
+							class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
+							title="Delete message"
+						>
+							<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+								<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+							</svg>
+						</button>
+					</div>
+				{/if}
+			</div>
+		</div>
+	{/each}
+	
+	{#if messageList.length === 0}
+		<div class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]">
+			<div class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4">
+				<div class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center">
+					<div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"></div>
+				</div>
+			</div>
+			<p class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase">AWAITING INPUT</p>
+			<p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">ENTER A QUERY TO BEGIN</p>
+		</div>
+	{/if}
+	
+	<!-- Scroll anchor for auto-scroll -->
+	<div bind:this={scrollAnchorRef}></div>
+</div>
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
new file mode 100644
index 00000000..87e06059
--- /dev/null
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -0,0 +1,430 @@
+<script lang="ts">
+import { 
+		conversations, 
+		activeConversationId, 
+		createConversation, 
+		loadConversation, 
+		deleteConversation, 
+		deleteAllConversations,
+		renameConversation,
+		clearChat,
+		instances,
+		debugMode,
+		toggleDebugMode
+	} from '$lib/stores/app.svelte';
+
+	interface Props {
+		class?: string;
+	}
+
+	let { class: className = '' }: Props = $props();
+
+	const conversationList = $derived(conversations());
+	const activeId = $derived(activeConversationId());
+const instanceData = $derived(instances());
+const debugEnabled = $derived(debugMode());
+
+	let searchQuery = $state('');
+	let editingId = $state<string | null>(null);
+	let editingName = $state('');
+	let deleteConfirmId = $state<string | null>(null);
+	let showDeleteAllConfirm = $state(false);
+
+	const filteredConversations = $derived(
+		searchQuery.trim()
+			? conversationList.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
+			: conversationList
+	);
+
+	function handleNewChat() {
+		createConversation();
+	}
+
+	function handleSelectConversation(id: string) {
+		loadConversation(id);
+	}
+
+	function handleStartEdit(id: string, name: string, event: MouseEvent) {
+		event.stopPropagation();
+		editingId = id;
+		editingName = name;
+	}
+
+	function handleSaveEdit() {
+		if (editingId && editingName.trim()) {
+			renameConversation(editingId, editingName.trim());
+		}
+		editingId = null;
+		editingName = '';
+	}
+
+	function handleCancelEdit() {
+		editingId = null;
+		editingName = '';
+	}
+
+	function handleEditKeydown(event: KeyboardEvent) {
+		if (event.key === 'Enter') {
+			handleSaveEdit();
+		} else if (event.key === 'Escape') {
+			handleCancelEdit();
+		}
+	}
+
+	function handleDeleteClick(id: string, event: MouseEvent) {
+		event.stopPropagation();
+		deleteConfirmId = id;
+	}
+
+	function handleConfirmDelete() {
+		if (deleteConfirmId) {
+			deleteConversation(deleteConfirmId);
+			deleteConfirmId = null;
+		}
+	}
+
+	function handleCancelDelete() {
+		deleteConfirmId = null;
+	}
+
+	function handleDeleteAllClick() {
+		showDeleteAllConfirm = true;
+	}
+
+	function handleConfirmDeleteAll() {
+		deleteAllConversations();
+		showDeleteAllConfirm = false;
+	}
+
+	function handleCancelDeleteAll() {
+		showDeleteAllConfirm = false;
+	}
+
+	function formatDate(timestamp: number): string {
+		const date = new Date(timestamp);
+		const now = new Date();
+		const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
+		
+		if (diffDays === 0) {
+			return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
+		} else if (diffDays === 1) {
+			return 'Yesterday';
+		} else if (diffDays < 7) {
+			return date.toLocaleDateString('en-US', { weekday: 'short' });
+		} else {
+			return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+		}
+	}
+
+	function getLastAssistantStats(conversation: typeof conversationList[0]): { ttftMs?: number; tps?: number } | null {
+		// Find the last assistant message with stats
+		for (let i = conversation.messages.length - 1; i >= 0; i--) {
+			const msg = conversation.messages[i];
+			if (msg.role === 'assistant' && (msg.ttftMs || msg.tps)) {
+				return { ttftMs: msg.ttftMs, tps: msg.tps };
+			}
+		}
+		return null;
+	}
+
+	function formatModelName(modelId: string | null | undefined): string {
+		if (!modelId) return 'Unknown Model';
+		const parts = modelId.split('/');
+		const tail = parts[parts.length - 1] || modelId;
+		return tail || modelId;
+	}
+
+	function formatStrategy(sharding: string | null | undefined, instanceType: string | null | undefined): string {
+		const shardLabel = sharding ?? 'Unknown';
+		const typeLabel = instanceType ?? null;
+		return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
+	}
+
+	function getTaggedValue(obj: unknown): [string | null, unknown] {
+		if (!obj || typeof obj !== 'object') return [null, null];
+		const keys = Object.keys(obj as Record<string, unknown>);
+		if (keys.length === 1) {
+			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+		}
+		return [null, null];
+	}
+
+	function extractInstanceModelId(instanceWrapped: unknown): string | null {
+		const [, instance] = getTaggedValue(instanceWrapped);
+		if (!instance || typeof instance !== 'object') return null;
+		const inst = instance as { shardAssignments?: { modelId?: string } };
+		return inst.shardAssignments?.modelId ?? null;
+	}
+
+	function describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
+		const [instanceTag, instance] = getTaggedValue(instanceWrapped);
+		if (!instance || typeof instance !== 'object') {
+			return { sharding: null, instanceType: null };
+		}
+
+		let instanceType: string | null = null;
+		if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+		else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+
+		let sharding: string | null = null;
+		const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+		const firstShardWrapped = Object.values(runnerToShard)[0];
+		if (firstShardWrapped) {
+			const [shardTag] = getTaggedValue(firstShardWrapped);
+			if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+			else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+			else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+		}
+
+		return { sharding, instanceType };
+	}
+
+	function resolveConversationInfo(conversation: typeof conversationList[0]): { modelLabel: string; strategyLabel: string } {
+		// Attempt to match conversation model to an instance
+		let matchedInstance: unknown = null;
+		let modelId = conversation.modelId ?? null;
+
+		if (modelId) {
+			for (const [, instanceWrapper] of Object.entries(instanceData)) {
+				const candidate = extractInstanceModelId(instanceWrapper);
+				if (candidate === modelId) {
+					matchedInstance = instanceWrapper;
+					break;
+				}
+			}
+		}
+
+		// Fallback: use the first available instance if no explicit match
+		if (!matchedInstance) {
+			const firstInstance = Object.values(instanceData)[0];
+			if (firstInstance) {
+				matchedInstance = firstInstance;
+				modelId = modelId ?? extractInstanceModelId(firstInstance);
+			}
+		}
+
+		const instanceDetails = matchedInstance ? describeInstance(matchedInstance) : { sharding: null, instanceType: null };
+		const displayModel = modelId ?? conversation.modelId ?? null;
+		const sharding = conversation.sharding ?? instanceDetails.sharding ?? 'Unknown';
+		const instanceType = conversation.instanceType ?? instanceDetails.instanceType;
+
+		return {
+			modelLabel: formatModelName(displayModel),
+			strategyLabel: formatStrategy(sharding, instanceType)
+		};
+	}
+</script>
+
+<aside class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}">
+	<!-- Header -->
+	<div class="p-4">
+		<button
+			onclick={handleNewChat}
+			class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
+		>
+			<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
+			</svg>
+			NEW CHAT
+		</button>
+	</div>
+
+	<!-- Search -->
+	<div class="px-4 py-3">
+		<div class="relative">
+			<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
+			</svg>
+			<input
+				type="text"
+				bind:value={searchQuery}
+				placeholder="Search conversations..."
+				class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
+			/>
+		</div>
+	</div>
+
+	<!-- Conversation List -->
+	<div class="flex-1 overflow-y-auto">
+		{#if filteredConversations.length > 0}
+			<div class="py-2">
+				<div class="px-4 py-2">
+					<span class="text-sm text-white/70 font-mono tracking-wider uppercase">
+						{searchQuery ? 'SEARCH RESULTS' : 'CONVERSATIONS'}
+					</span>
+				</div>
+				
+				{#each filteredConversations as conversation (conversation.id)}
+					{@const info = resolveConversationInfo(conversation)}
+					<div class="px-2">
+						{#if editingId === conversation.id}
+							<!-- Edit mode -->
+							<div class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1">
+								<input
+									type="text"
+									bind:value={editingName}
+									onkeydown={handleEditKeydown}
+									class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
+									autofocus
+								/>
+								<div class="flex gap-2">
+									<button
+										onclick={handleSaveEdit}
+										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
+									>
+										SAVE
+									</button>
+									<button
+										onclick={handleCancelEdit}
+										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+									>
+										CANCEL
+									</button>
+								</div>
+							</div>
+						{:else if deleteConfirmId === conversation.id}
+							<!-- Delete confirmation -->
+							<div class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1">
+								<p class="text-xs text-red-400 mb-2">Delete "{conversation.name}"?</p>
+								<div class="flex gap-2">
+									<button
+										onclick={handleConfirmDelete}
+										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
+									>
+										DELETE
+									</button>
+									<button
+										onclick={handleCancelDelete}
+										class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+									>
+										CANCEL
+									</button>
+								</div>
+							</div>
+						{:else}
+							<!-- Normal view -->
+							{@const stats = getLastAssistantStats(conversation)}
+							<div
+								role="button"
+								tabindex="0"
+								onclick={() => handleSelectConversation(conversation.id)}
+								onkeydown={(e) => e.key === 'Enter' && handleSelectConversation(conversation.id)}
+								class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
+									{activeId === conversation.id 
+								? 'bg-transparent border border-exo-yellow/30' 
+									: 'hover:border-exo-yellow/20 border border-transparent'}"
+							>
+								<div class="flex-1 min-w-0 pr-2">
+									<div class="text-sm truncate {activeId === conversation.id ? 'text-exo-yellow' : 'text-white/90'}">
+										{conversation.name}
+									</div>
+									<div class="text-sm text-white/50 mt-0.5">
+										{formatDate(conversation.updatedAt)}
+									</div>
+									<div class="text-sm text-white/70 truncate">
+										{info.modelLabel}
+									</div>
+									<div class="text-xs text-white/60 font-mono">
+										Strategy: <span class="text-white/80">{info.strategyLabel}</span>
+									</div>
+									{#if stats}
+										<div class="text-xs text-white/60 font-mono mt-1">
+											{#if stats.ttftMs}<span class="text-white/40">TTFT</span> {stats.ttftMs.toFixed(0)}ms{/if}{#if stats.ttftMs && stats.tps}<span class="text-white/30 mx-1.5">•</span>{/if}{#if stats.tps}{stats.tps.toFixed(1)} <span class="text-white/40">tok/s</span>{/if}
+										</div>
+									{/if}
+								</div>
+								
+								<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+								<button
+									type="button"
+									onclick={(e) => handleStartEdit(conversation.id, conversation.name, e)}
+									class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
+									title="Rename"
+								>
+										<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
+										</svg>
+									</button>
+								<button
+									type="button"
+									onclick={(e) => handleDeleteClick(conversation.id, e)}
+									class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+									title="Delete"
+								>
+										<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+										</svg>
+									</button>
+								</div>
+							</div>
+						{/if}
+					</div>
+				{/each}
+			</div>
+		{:else}
+			<div class="flex flex-col items-center justify-center h-full p-4 text-center">
+				<div class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3">
+					<svg class="w-6 h-6 text-exo-yellow/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+						<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+					</svg>
+				</div>
+				<p class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1">
+					{searchQuery ? 'NO RESULTS' : 'NO CONVERSATIONS'}
+				</p>
+				<p class="text-sm text-white/50">
+					{searchQuery ? 'Try a different search' : 'Start a new chat to begin'}
+				</p>
+			</div>
+		{/if}
+	</div>
+
+	<!-- Footer -->
+	<div class="p-3 border-t border-exo-yellow/10">
+		{#if showDeleteAllConfirm}
+			<div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
+				<p class="text-xs text-red-400 text-center mb-2">Delete all {conversationList.length} conversations?</p>
+				<div class="flex gap-2">
+				<button
+					onclick={handleConfirmDeleteAll}
+					class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+				>
+					DELETE ALL
+				</button>
+				<button
+					onclick={handleCancelDeleteAll}
+					class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+				>
+						CANCEL
+					</button>
+				</div>
+			</div>
+		{:else if conversationList.length > 0}
+		<button
+			onclick={handleDeleteAllClick}
+			class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
+		>
+				<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+				</svg>
+				DELETE ALL CHATS
+			</button>
+		{/if}
+	<div class="flex items-center justify-center gap-3 {conversationList.length > 0 && !showDeleteAllConfirm ? 'mt-2' : ''}">
+		<button
+			type="button"
+			onclick={toggleDebugMode}
+			class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+			title="Toggle debug mode"
+		>
+			<svg class="w-4 h-4 {debugEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="currentColor" viewBox="0 0 24 24">
+				<path d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"/>
+			</svg>
+		</button>
+		<div class="text-xs text-white/60 font-mono tracking-wider text-center">
+			{conversationList.length} CONVERSATION{conversationList.length !== 1 ? 'S' : ''}
+		</div>
+	</div>
+	</div>
+</aside>
+
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
new file mode 100644
index 00000000..4ec770d6
--- /dev/null
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+	import { browser } from '$app/environment';
+
+	export let showHome = true;
+	export let onHome: (() => void) | null = null;
+
+	function handleHome(): void {
+		if (onHome) {
+			onHome();
+			return;
+		}
+		if (browser) {
+			// Hash router: send to root
+			window.location.hash = '/';
+		}
+	}
+</script>
+
+<header class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray">
+	<!-- Center: Logo (clickable to go home) -->
+	<button
+		onclick={handleHome}
+		class="hover:opacity-80 transition-opacity {showHome ? 'cursor-pointer' : 'cursor-default'}"
+		title={showHome ? 'Go to home' : ''}
+		disabled={!showHome}
+	>
+		<img src="/exo-logo.png" alt="EXO" class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]" />
+	</button>
+
+	<!-- Right: Home + Downloads -->
+	<div class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4">
+		{#if showHome}
+			<button
+				onclick={handleHome}
+				class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+				title="Back to topology view"
+			>
+				<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+					<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
+				</svg>
+				Home
+			</button>
+		{/if}
+		<a
+			href="/#/downloads"
+			class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+			title="View downloads overview"
+		>
+			<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+				<path d="M12 3v12" />
+				<path d="M7 12l5 5 5-5" />
+				<path d="M5 21h14" />
+			</svg>
+			Downloads
+		</a>
+	</div>
+</header>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
new file mode 100644
index 00000000..ee5f07ab
--- /dev/null
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -0,0 +1,660 @@
+<script lang="ts">
+	import type { DownloadProgress, NodeInfo, PlacementPreview } from '$lib/stores/app.svelte';
+
+interface Props {
+		model: { id: string; name?: string; storage_size_megabytes?: number };
+		isLaunching?: boolean;
+		downloadStatus?: {
+			isDownloading: boolean;
+			progress: DownloadProgress | null;
+		perNode?: Array<{
+			nodeId: string;
+			nodeName: string;
+			progress: DownloadProgress;
+		}>;
+		} | null;
+		nodes?: Record<string, NodeInfo>;
+		sharding?: 'Pipeline' | 'Tensor';
+		runtime?: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+		onLaunch?: () => void;
+		tags?: string[];
+		apiPreview?: PlacementPreview | null;
+		modelIdOverride?: string | null;
+	}
+
+	let { 
+		model, 
+		isLaunching = false, 
+		downloadStatus = null, 
+		nodes = {},
+		sharding = 'Pipeline',
+		runtime = 'MlxRing',
+		onLaunch,
+		tags = [],
+		apiPreview = null,
+		modelIdOverride = null
+	}: Props = $props();
+
+	// Estimate memory requirements from model name
+	// Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+	function estimateMemoryGB(modelId: string, modelName?: string): number {
+		// Check both ID and name for quantization info
+		const combined = `${modelId} ${modelName || ''}`.toLowerCase();
+		
+		// Detect quantization level - affects memory by roughly 2x between levels
+		const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
+		const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
+		// 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+		const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+		const id = modelId.toLowerCase();
+		
+		// Known large models that don't follow the standard naming pattern
+		// DeepSeek V3 has 685B parameters
+		if (id.includes('deepseek-v3')) {
+			return Math.round(685 * quantMultiplier);
+		}
+		// DeepSeek V2 has 236B parameters  
+		if (id.includes('deepseek-v2')) {
+			return Math.round(236 * quantMultiplier);
+		}
+		// Llama 4 Scout/Maverick are large models
+		if (id.includes('llama-4')) {
+			return Math.round(400 * quantMultiplier);
+		}
+		
+		// Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+		const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+		if (paramMatch) {
+			const params = parseFloat(paramMatch[1]);
+			return Math.max(4, Math.round(params * quantMultiplier));
+		}
+		
+		// Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+		if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
+		if (id.includes('180b')) return Math.round(180 * quantMultiplier);
+		if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
+		if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
+		if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
+		if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
+		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
+		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
+		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
+		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
+		if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
+		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
+		
+		return 16; // Default fallback
+	}
+
+	function formatBytes(bytes: number, decimals = 1): string {
+		if (!bytes || bytes === 0) return '0 B';
+		const k = 1024;
+		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+		const i = Math.floor(Math.log(bytes) / Math.log(k));
+		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
+	}
+
+	function formatSpeed(bps: number): string {
+		if (!bps || bps <= 0) return '0 B/s';
+		return formatBytes(bps) + '/s';
+	}
+
+	function formatEta(ms: number): string {
+		if (!ms || ms <= 0) return '--';
+		const totalSeconds = Math.round(ms / 1000);
+		const s = totalSeconds % 60;
+		const m = Math.floor(totalSeconds / 60) % 60;
+		const h = Math.floor(totalSeconds / 3600);
+		if (h > 0) return `${h}h ${m}m`;
+		if (m > 0) return `${m}m ${s}s`;
+		return `${s}s`;
+	}
+
+	const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
+	const progress = $derived(downloadStatus?.progress);
+	const percentage = $derived(progress?.percentage ?? 0);
+let expandedNodes = $state<Set<string>>(new Set());
+
+function toggleNodeDetails(nodeId: string): void {
+	const next = new Set(expandedNodes);
+	if (next.has(nodeId)) {
+		next.delete(nodeId);
+	} else {
+		next.add(nodeId);
+	}
+	expandedNodes = next;
+}
+	
+	// Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
+	const estimatedMemory = $derived(
+		model.storage_size_megabytes 
+			? Math.round(model.storage_size_megabytes / 1024)
+			: estimateMemoryGB(model.id, model.name)
+	);
+	
+	function getDeviceType(name: string): 'macbook' | 'studio' | 'mini' | 'unknown' {
+		const lower = name.toLowerCase();
+		if (lower.includes('macbook')) return 'macbook';
+		if (lower.includes('studio')) return 'studio';
+		if (lower.includes('mini')) return 'mini';
+		return 'unknown';
+	}
+	
+	const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
+	const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
+
+	// Get node list in the same order as the topology graph (insertion order of
+	// topology nodes), while still ensuring preview nodes render even if the
+	// topology payload is missing them. Topology order is preserved exactly so
+	// that the mini preview matches the main TopologyGraph layout.
+	const nodeList = $derived(() => {
+		const nodesFromTopology = Object.keys(nodes).map((id) => {
+			const info = nodes[id];
+			const totalBytes = info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
+			const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
+			const availableBytes = Math.max(totalBytes - usedBytes, 0);
+			const totalGB = totalBytes / (1024 * 1024 * 1024);
+			const availableGB = availableBytes / (1024 * 1024 * 1024);
+			const usedGB = Math.max(totalGB - availableGB, 0);
+			const deviceName = info.system_info?.model_id ?? 'Unknown';
+			const deviceType = getDeviceType(deviceName);
+
+			return { id, totalGB, availableGB, usedGB, deviceName, deviceType, usedBytes, totalBytes };
+		});
+
+		const previewEntries = apiPreview?.memory_delta_by_node ?? null;
+		const previewIds = previewEntries ? Object.keys(previewEntries) : [];
+
+		if (previewIds.length === 0) return nodesFromTopology;
+
+		// Append any preview-only nodes (not in topology) at the end
+		const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
+		const extraPreviewNodes = previewIds
+			.filter((id) => !topologyIds.has(id))
+			.map((id) => {
+				const deltaBytes = previewEntries?.[id] ?? 0;
+				const deltaGB = deltaBytes / (1024 * 1024 * 1024);
+				const totalGB = Math.max(deltaGB * 1.2, 1);
+				const usedGB = Math.max(totalGB - deltaGB, 0);
+
+				return {
+					id,
+					totalGB,
+					availableGB: Math.max(totalGB - usedGB, 0),
+					usedGB,
+					deviceName: 'Unknown',
+					deviceType: 'unknown' as const,
+					usedBytes: usedGB * 1024 * 1024 * 1024,
+					totalBytes: totalGB * 1024 * 1024 * 1024
+				};
+			});
+
+		return [...nodesFromTopology, ...extraPreviewNodes];
+	});
+	
+	// Calculate placement preview with all SVG metrics pre-computed
+	// Uses API preview data when available, falls back to local estimation
+	const placementPreview = $derived(() => {
+		const nodeArray = nodeList();
+		if (nodeArray.length === 0) return { nodes: [], canFit: false, totalAvailable: 0, error: null };
+		
+		const numNodes = nodeArray.length;
+		const iconSize = numNodes === 1 ? 50 : 36;
+		const topoWidth = 260;
+		const topoHeight = numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
+		const centerX = topoWidth / 2;
+		const centerY = topoHeight / 2;
+		const radius = numNodes === 1 ? 0 : numNodes === 2 ? 45 : Math.min(topoWidth, topoHeight) * 0.32;
+		
+		// Use API preview data if available
+		const hasApiPreview = apiPreview !== null && apiPreview.error === null && apiPreview.memory_delta_by_node !== null;
+		const canFit = hasApiPreview ? true : (() => {
+			const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+			return totalAvailable >= estimatedMemory;
+		})();
+		const error = apiPreview?.error ?? null;
+		
+		let placementNodes: Array<{ 
+			id: string;
+			deviceName: string;
+			deviceType: 'macbook' | 'studio' | 'mini' | 'unknown';
+			totalGB: number; 
+			currentUsedGB: number;
+			modelUsageGB: number;
+			currentPercent: number;
+			newPercent: number;
+			isUsed: boolean;
+			x: number;
+			y: number;
+			iconSize: number;
+			screenHeight: number;
+			currentFillHeight: number;
+			modelFillHeight: number;
+		}> = [];
+		
+		if (hasApiPreview && apiPreview.memory_delta_by_node) {
+			// Use API placement data
+			const memoryDelta = apiPreview.memory_delta_by_node;
+			placementNodes = nodeArray.map((n, i) => {
+				const deltaBytes = memoryDelta[n.id] ?? 0;
+				const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
+				const isUsed = deltaBytes > 0;
+				const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+				const safeTotal = Math.max(n.totalGB, 0.001);
+				const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+				const newPercent = clampPercent(((n.usedGB + modelUsageGB) / safeTotal) * 100);
+				const screenHeight = iconSize * 0.58;
+				
+				return {
+					id: n.id,
+					deviceName: n.deviceName,
+					deviceType: n.deviceType,
+					totalGB: n.totalGB,
+					currentUsedGB: n.usedGB,
+					modelUsageGB,
+					currentPercent,
+					newPercent,
+					isUsed,
+					x: centerX + Math.cos(angle) * radius,
+					y: centerY + Math.sin(angle) * radius,
+					iconSize,
+					screenHeight,
+					currentFillHeight: screenHeight * (currentPercent / 100),
+					modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+				};
+			});
+		} else if (apiPreview?.error) {
+			// API returned an error - model can't fit, show all nodes as unused
+			placementNodes = nodeArray.map((n, i) => {
+				const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+				const safeTotal = Math.max(n.totalGB, 0.001);
+				const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+				const screenHeight = iconSize * 0.58;
+				
+				return {
+					id: n.id,
+					deviceName: n.deviceName,
+					deviceType: n.deviceType,
+					totalGB: n.totalGB,
+					currentUsedGB: n.usedGB,
+					modelUsageGB: 0,
+					currentPercent,
+					newPercent: currentPercent,
+					isUsed: false,
+					x: centerX + Math.cos(angle) * radius,
+					y: centerY + Math.sin(angle) * radius,
+					iconSize,
+					screenHeight,
+					currentFillHeight: screenHeight * (currentPercent / 100),
+					modelFillHeight: 0
+				};
+			});
+		} else {
+			// Fallback: local estimation based on sharding strategy
+			const memoryNeeded = estimatedMemory;
+			
+			if (sharding === 'Pipeline') {
+				const memoryPerNode = memoryNeeded / numNodes;
+				placementNodes = nodeArray.map((n, i) => {
+					const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+					const safeTotal = Math.max(n.totalGB, 0.001);
+					const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+					const newPercent = clampPercent(((n.usedGB + memoryPerNode) / safeTotal) * 100);
+					const screenHeight = iconSize * 0.58;
+					
+					return {
+						id: n.id,
+						deviceName: n.deviceName,
+						deviceType: n.deviceType,
+						totalGB: n.totalGB,
+						currentUsedGB: n.usedGB,
+						modelUsageGB: memoryPerNode,
+						currentPercent,
+						newPercent,
+						isUsed: true,
+						x: centerX + Math.cos(angle) * radius,
+						y: centerY + Math.sin(angle) * radius,
+						iconSize,
+						screenHeight,
+						currentFillHeight: screenHeight * (currentPercent / 100),
+						modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+					};
+				});
+			} else {
+				let remaining = memoryNeeded;
+				placementNodes = nodeArray.map((n, i) => {
+					const allocated = Math.min(remaining, n.availableGB);
+					remaining -= allocated;
+					const isUsed = allocated > 0;
+					const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+					const safeTotal = Math.max(n.totalGB, 0.001);
+					const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+					const newPercent = clampPercent(((n.usedGB + allocated) / safeTotal) * 100);
+					const screenHeight = iconSize * 0.58;
+					
+					return {
+						id: n.id,
+						deviceName: n.deviceName,
+						deviceType: n.deviceType,
+						totalGB: n.totalGB,
+						currentUsedGB: n.usedGB,
+						modelUsageGB: allocated,
+						currentPercent,
+						newPercent,
+						isUsed,
+						x: centerX + Math.cos(angle) * radius,
+						y: centerY + Math.sin(angle) * radius,
+						iconSize,
+						screenHeight,
+						currentFillHeight: screenHeight * (currentPercent / 100),
+						modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
+					};
+				});
+			}
+		}
+		
+		const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+		return { nodes: placementNodes, canFit: hasApiPreview || canFit, totalAvailable, topoWidth, topoHeight, error };
+	});
+	
+	const canFit = $derived(apiPreview ? apiPreview.error === null : placementPreview().canFit);
+	const placementError = $derived(apiPreview?.error ?? null);
+	const nodeCount = $derived(nodeList().length);
+	const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ''));
+</script>
+
+<div class="relative group">
+	<!-- Corner accents -->
+	<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+	<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+	<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+	<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
+	
+	<div class="bg-exo-dark-gray/60 border {canFit ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40' : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]">
+		<!-- Model Name & Memory Required -->
+		<div class="flex items-start justify-between gap-2 mb-2">
+			<div class="flex-1 min-w-0">
+			<div class="flex items-center gap-2">
+				<div class="text-exo-yellow text-xs font-mono tracking-wide truncate" title={model.name || model.id}>
+					{model.name || model.id}
+				</div>
+				{#if huggingFaceModelId}
+					<a
+						class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
+						href={`https://huggingface.co/${huggingFaceModelId}`}
+						target="_blank"
+						rel="noreferrer noopener"
+						aria-label="View model on Hugging Face"
+					>
+						<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+							<path d="M14 3h7v7"/>
+							<path d="M10 14l11-11"/>
+							<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+						</svg>
+					</a>
+				{/if}
+					{#if tags.length > 0}
+						<div class="flex gap-1 flex-shrink-0">
+							{#each tags as tag}
+								<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag === 'FASTEST' ? 'bg-green-500/20 text-green-400 border border-green-500/30' : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}">
+									{tag}
+								</span>
+							{/each}
+						</div>
+					{/if}
+				</div>
+				{#if model.name && model.name !== model.id}
+					<div class="text-xs text-exo-light-gray font-mono truncate mt-0.5" title={model.id}>
+						{model.id}
+					</div>
+				{/if}
+			</div>
+			<div class="flex-shrink-0 text-right">
+				<div class="text-xs font-mono {canFit ? 'text-exo-yellow' : 'text-red-400'}">
+					{estimatedMemory}GB
+				</div>
+			</div>
+		</div>
+		
+		<!-- Configuration Badge -->
+		<div class="flex items-center gap-1.5 mb-2">
+			<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
+				{sharding}
+			</span>
+		<span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
+			{runtime === 'MlxRing' ? 'MLX Ring' : runtime === 'MlxIbv' || runtime === 'MlxJaccl' ? 'MLX RDMA' : runtime}
+		</span>
+		</div>
+		
+		<!-- Mini Topology Preview -->
+		{#if placementPreview().nodes.length > 0}
+			{@const preview = placementPreview()}
+			<div class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden">
+				<!-- Scanline effect -->
+				<div class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"></div>
+				
+				<svg width="100%" height={preview.topoHeight} viewBox="0 0 {preview.topoWidth} {preview.topoHeight}" class="overflow-visible">
+					<defs>
+						<!-- Glow filter for active nodes -->
+						<filter id="nodeGlow-{filterId}" x="-50%" y="-50%" width="200%" height="200%">
+							<feGaussianBlur stdDeviation="2" result="blur"/>
+							<feMerge>
+								<feMergeNode in="blur"/>
+								<feMergeNode in="SourceGraphic"/>
+							</feMerge>
+						</filter>
+						
+						<!-- Strong glow for new memory -->
+						<filter id="memGlow-{filterId}" x="-100%" y="-100%" width="300%" height="300%">
+							<feGaussianBlur stdDeviation="3" result="blur"/>
+							<feComposite in="SourceGraphic" in2="blur" operator="over"/>
+						</filter>
+					</defs>
+					
+					<!-- Connection lines between nodes (if multiple) -->
+					{#if preview.nodes.length > 1}
+						{#each preview.nodes as node, i}
+							{#each preview.nodes.slice(i + 1) as node2}
+								<line 
+									x1={node.x} y1={node.y} x2={node2.x} y2={node2.y}
+									stroke={node.isUsed && node2.isUsed ? '#FFD700' : '#374151'}
+									stroke-width="1"
+									stroke-dasharray={node.isUsed && node2.isUsed ? '4,2' : '2,4'}
+									opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
+								/>
+							{/each}
+						{/each}
+					{/if}
+					
+					{#each preview.nodes as node}
+						<g 
+							transform="translate({node.x}, {node.y})"
+							opacity={node.isUsed ? 1 : 0.25}
+							filter={node.isUsed ? `url(#nodeGlow-${filterId})` : 'none'}
+						>
+							<!-- Device icon based on type -->
+							{#if node.deviceType === 'macbook'}
+								<!-- MacBook Pro icon with memory fill -->
+								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+									<!-- Screen bezel -->
+									<rect 
+										x="2" y="0" 
+										width={node.iconSize - 4} height={node.iconSize * 0.65} 
+										rx="2"
+										fill="none"
+										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+										stroke-width="1.5"
+									/>
+									<!-- Screen area (memory fill container) -->
+									<rect 
+										x="4" y="2" 
+										width={node.iconSize - 8} height={node.screenHeight}
+										fill="#0a0a0a"
+									/>
+									<!-- Current memory fill (gray) -->
+									<rect 
+										x="4" 
+										y={2 + node.screenHeight - node.currentFillHeight}
+										width={node.iconSize - 8} 
+										height={node.currentFillHeight}
+										fill="#374151"
+									/>
+									<!-- New model memory fill (glowing yellow) -->
+									{#if node.modelUsageGB > 0 && node.isUsed}
+										<rect 
+											x="4" 
+											y={2 + node.screenHeight - node.currentFillHeight - node.modelFillHeight}
+											width={node.iconSize - 8} 
+											height={node.modelFillHeight}
+											fill="#FFD700"
+											filter="url(#memGlow-{filterId})"
+											class="animate-pulse-slow"
+										/>
+									{/if}
+									<!-- Base/keyboard -->
+									<path 
+										d="M 0 {node.iconSize * 0.68} L {node.iconSize} {node.iconSize * 0.68} L {node.iconSize - 2} {node.iconSize * 0.78} L 2 {node.iconSize * 0.78} Z"
+										fill="none"
+										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+										stroke-width="1.5"
+									/>
+								</g>
+							{:else if node.deviceType === 'studio'}
+								<!-- Mac Studio icon -->
+								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+									<rect 
+										x="2" y="2" 
+										width={node.iconSize - 4} height={node.iconSize - 4} 
+										rx="4"
+										fill="none"
+										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+										stroke-width="1.5"
+									/>
+									<!-- Memory fill background -->
+									<rect 
+										x="4" y="4" 
+										width={node.iconSize - 8} height={node.iconSize - 8}
+										fill="#0a0a0a"
+									/>
+									<!-- Current memory fill -->
+									<rect 
+										x="4" 
+										y={4 + (node.iconSize - 8) * (1 - node.currentPercent / 100)}
+										width={node.iconSize - 8} 
+										height={(node.iconSize - 8) * (node.currentPercent / 100)}
+										fill="#374151"
+									/>
+									<!-- New model memory fill -->
+									{#if node.modelUsageGB > 0 && node.isUsed}
+										<rect 
+											x="4" 
+											y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
+											width={node.iconSize - 8} 
+											height={(node.iconSize - 8) * ((node.newPercent - node.currentPercent) / 100)}
+											fill="#FFD700"
+											filter="url(#memGlow-{filterId})"
+											class="animate-pulse-slow"
+										/>
+									{/if}
+								</g>
+							{:else if node.deviceType === 'mini'}
+								<!-- Mac Mini icon -->
+								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+									<rect 
+										x="2" y={node.iconSize * 0.3} 
+										width={node.iconSize - 4} height={node.iconSize * 0.4} 
+										rx="3"
+										fill="none"
+										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+										stroke-width="1.5"
+									/>
+									<!-- Memory fill background -->
+									<rect 
+										x="4" y={node.iconSize * 0.32} 
+										width={node.iconSize - 8} height={node.iconSize * 0.36}
+										fill="#0a0a0a"
+									/>
+									<!-- Current memory fill -->
+									<rect 
+										x="4" 
+										y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.currentPercent / 100)}
+										width={node.iconSize - 8} 
+										height={(node.iconSize * 0.36) * (node.currentPercent / 100)}
+										fill="#374151"
+									/>
+									<!-- New model memory fill -->
+									{#if node.modelUsageGB > 0 && node.isUsed}
+										<rect 
+											x="4" 
+											y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.newPercent / 100)}
+											width={node.iconSize - 8} 
+											height={(node.iconSize * 0.36) * ((node.newPercent - node.currentPercent) / 100)}
+											fill="#FFD700"
+											filter="url(#memGlow-{filterId})"
+											class="animate-pulse-slow"
+										/>
+									{/if}
+								</g>
+							{:else}
+								<!-- Unknown device - hexagon -->
+								<g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
+									<polygon 
+										points="{node.iconSize/2},0 {node.iconSize},{node.iconSize*0.25} {node.iconSize},{node.iconSize*0.75} {node.iconSize/2},{node.iconSize} 0,{node.iconSize*0.75} 0,{node.iconSize*0.25}"
+										fill={node.isUsed ? 'rgba(255,215,0,0.1)' : '#0a0a0a'}
+										stroke={node.isUsed ? '#FFD700' : '#4B5563'}
+										stroke-width="1.5"
+									/>
+								</g>
+							{/if}
+							
+							<!-- Percentage label -->
+							<text 
+								y={node.iconSize/2 + 12}
+								text-anchor="middle"
+								font-size="8"
+								font-family="SF Mono, Monaco, monospace"
+								fill={node.isUsed ? (node.newPercent > 90 ? '#f87171' : '#FFD700') : '#4B5563'}
+							>
+								{node.newPercent.toFixed(0)}%
+							</text>
+						</g>
+					{/each}
+				</svg>
+			</div>
+		{/if}
+
+		<!-- Launch Button -->
+		<button
+			onclick={onLaunch}
+			disabled={isLaunching || !canFit}
+			class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200 
+				{isLaunching 
+					? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait' 
+					: !canFit
+						? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
+						: 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'
+				}"
+		>
+			{#if isLaunching}
+				<span class="flex items-center justify-center gap-1.5">
+					<span class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"></span>
+					LAUNCHING...
+				</span>
+			{:else if !canFit}
+				INSUFFICIENT MEMORY
+			{:else}
+				▸ LAUNCH
+			{/if}
+		</button>
+	</div>
+</div>
+
+<style>
+	@keyframes pulse-slow {
+		0%, 100% { opacity: 0.8; }
+		50% { opacity: 1; }
+	}
+	.animate-pulse-slow {
+		animation: pulse-slow 1.5s ease-in-out infinite;
+	}
+</style>
diff --git a/dashboard/src/lib/components/TopologyGraph.svelte b/dashboard/src/lib/components/TopologyGraph.svelte
new file mode 100644
index 00000000..e45ca080
--- /dev/null
+++ b/dashboard/src/lib/components/TopologyGraph.svelte
@@ -0,0 +1,971 @@
+<script lang="ts">
+	import { onMount, onDestroy } from 'svelte';
+	import * as d3 from 'd3';
+import { topologyData, isTopologyMinimized, debugMode } from '$lib/stores/app.svelte';
+
+	interface Props {
+		class?: string;
+		highlightedNodes?: Set<string>;
+	}
+
+	let { class: className = '', highlightedNodes = new Set() }: Props = $props();
+
+	let svgContainer: SVGSVGElement | undefined = $state();
+	let resizeObserver: ResizeObserver | undefined;
+
+const isMinimized = $derived(isTopologyMinimized());
+const data = $derived(topologyData());
+const debugEnabled = $derived(debugMode());
+
+function getNodeLabel(nodeId: string): string {
+	const node = data?.nodes?.[nodeId];
+	return node?.friendly_name || nodeId.slice(0, 8);
+}
+
+function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
+	if (!ip) return { label: '?', missing: true };
+	const node = data?.nodes?.[nodeId];
+	if (!node) return { label: '?', missing: true };
+
+	const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+		(iface.addresses || []).some((addr) => addr === ip)
+	);
+	if (matchFromInterfaces?.name) {
+		return { label: matchFromInterfaces.name, missing: false };
+	}
+
+	const mapped = node.ip_to_interface?.[ip];
+	if (mapped && mapped.trim().length > 0) {
+		return { label: mapped, missing: false };
+	}
+
+	return { label: '?', missing: true };
+}
+
+function wrapLine(text: string, maxLen: number): string[] {
+	if (text.length <= maxLen) return [text];
+	const words = text.split(' ');
+	const lines: string[] = [];
+	let current = '';
+	for (const word of words) {
+		if (word.length > maxLen) {
+			if (current) {
+				lines.push(current);
+				current = '';
+			}
+			for (let i = 0; i < word.length; i += maxLen) {
+				lines.push(word.slice(i, i + maxLen));
+			}
+		} else if ((current + ' ' + word).trim().length > maxLen) {
+			lines.push(current);
+			current = word;
+		} else {
+			current = current ? `${current} ${word}` : word;
+		}
+	}
+	if (current) lines.push(current);
+	return lines;
+}
+
+	// Apple logo path for MacBook Pro screen
+	const APPLE_LOGO_PATH = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
+	const LOGO_NATIVE_WIDTH = 814;
+	const LOGO_NATIVE_HEIGHT = 1000;
+
+	function formatBytes(bytes: number, decimals = 1): string {
+		if (!bytes || bytes === 0) return '0B';
+		const k = 1024;
+		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+		const i = Math.floor(Math.log(bytes) / Math.log(k));
+		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
+	}
+
+	function getTemperatureColor(temp: number): string {
+		// Default for N/A temp - light gray
+		if (isNaN(temp) || temp === null) return 'rgba(179, 179, 179, 0.8)';
+		
+		const coolTemp = 45;  // Temp for pure blue
+		const midTemp = 57.5; // Temp for pure yellow
+		const hotTemp = 75;   // Temp for pure red
+		
+		const coolColor = { r: 93, g: 173, b: 226 };  // #5DADE2 (Blue)
+		const midColor = { r: 255, g: 215, b: 0 };    // #FFD700 (Yellow)
+		const hotColor = { r: 244, g: 67, b: 54 };    // #F44336 (Red)
+		
+		let r: number, g: number, b: number;
+		
+		if (temp <= coolTemp) {
+			({ r, g, b } = coolColor);
+		} else if (temp <= midTemp) {
+			const ratio = (temp - coolTemp) / (midTemp - coolTemp);
+			r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
+			g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
+			b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
+		} else if (temp < hotTemp) {
+			const ratio = (temp - midTemp) / (hotTemp - midTemp);
+			r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
+			g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
+			b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
+		} else {
+			({ r, g, b } = hotColor);
+		}
+		
+		return `rgb(${r}, ${g}, ${b})`;
+	}
+
+	function renderGraph() {
+		if (!svgContainer || !data) return;
+
+		d3.select(svgContainer).selectAll('*').remove();
+
+		const nodes = data.nodes || {};
+		const edges = data.edges || [];
+		const nodeIds = Object.keys(nodes);
+
+		const rect = svgContainer.getBoundingClientRect();
+		const width = rect.width;
+		const height = rect.height;
+		const centerX = width / 2;
+		const centerY = height / 2;
+
+		const svg = d3.select(svgContainer);
+
+		// Add defs for clip paths and filters
+		const defs = svg.append('defs');
+		
+		// Glow filter
+		const glowFilter = defs.append('filter')
+			.attr('id', 'glow')
+			.attr('x', '-50%')
+			.attr('y', '-50%')
+			.attr('width', '200%')
+			.attr('height', '200%');
+		glowFilter.append('feGaussianBlur')
+			.attr('stdDeviation', '2')
+			.attr('result', 'coloredBlur');
+		const glowMerge = glowFilter.append('feMerge');
+		glowMerge.append('feMergeNode').attr('in', 'coloredBlur');
+		glowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
+
+		// Arrowhead marker for directional edges
+		const marker = defs.append('marker')
+			.attr('id', 'arrowhead')
+			.attr('viewBox', '0 0 10 10')
+			.attr('refX', '10')
+			.attr('refY', '5')
+			.attr('markerWidth', '11')
+			.attr('markerHeight', '11')
+			.attr('orient', 'auto-start-reverse');
+		marker.append('path')
+			.attr('d', 'M 0 0 L 10 5 L 0 10')
+			.attr('fill', 'none')
+			.attr('stroke', 'var(--exo-light-gray, #B3B3B3)')
+			.attr('stroke-width', '1.6')
+			.attr('stroke-linecap', 'round')
+			.attr('stroke-linejoin', 'round')
+			.style('animation', 'none');
+
+		if (nodeIds.length === 0) {
+			svg.append('text')
+				.attr('x', centerX)
+				.attr('y', centerY)
+				.attr('text-anchor', 'middle')
+				.attr('dominant-baseline', 'middle')
+				.attr('fill', 'rgba(255,215,0,0.4)')
+				.attr('font-size', isMinimized ? 10 : 12)
+				.attr('font-family', 'SF Mono, monospace')
+				.attr('letter-spacing', '0.1em')
+				.text('AWAITING NODES');
+			return;
+		}
+
+		const numNodes = nodeIds.length;
+		const minDimension = Math.min(width, height);
+		
+		// Dynamic scaling - larger nodes for big displays
+		const sizeScale = numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.10);
+		const baseNodeRadius = isMinimized
+			? Math.max(36, Math.min(60, minDimension * 0.22))
+			: Math.min(120, minDimension * 0.20);
+		const nodeRadius = baseNodeRadius * sizeScale;
+		
+		// Orbit radius - balanced spacing for nodes
+		const circumference = numNodes * nodeRadius * 4;
+		const radiusFromCircumference = circumference / (2 * Math.PI);
+		const minOrbitRadius = Math.max(radiusFromCircumference, minDimension * 0.18);
+		const maxOrbitRadius = minDimension * 0.30;
+		const orbitRadius = isMinimized 
+			? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
+			: Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)));
+		
+		// Determine display mode based on space and node count
+		const showFullLabels = !isMinimized && numNodes <= 4;
+		const showCompactLabels = !isMinimized && numNodes > 4;
+
+		// Add padding for labels (top/bottom)
+		const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
+		const bottomPadding = 70; // Space for stats and bottom label
+		const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
+		
+		// Calculate node positions
+		const nodesWithPositions = nodeIds.map((id, index) => {
+			if (numNodes === 1) {
+				// Single node: center it
+				return {
+					id,
+					data: nodes[id],
+					x: centerX,
+					y: safeCenterY
+				};
+			}
+			// Distribute nodes around the orbit
+			// Start from top (-90 degrees) and go clockwise
+			const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2);
+			return {
+				id,
+				data: nodes[id],
+				x: centerX + orbitRadius * Math.cos(angle),
+				y: safeCenterY + orbitRadius * Math.sin(angle)
+			};
+		});
+
+		const positionById: Record<string, { x: number; y: number }> = {};
+		nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
+
+		// Draw edges
+		const linksGroup = svg.append('g').attr('class', 'links-group');
+		const arrowsGroup = svg.append('g').attr('class', 'arrows-group');
+		const debugLabelsGroup = svg.append('g').attr('class', 'debug-edge-labels');
+
+		const pairMap = new Map<string, { a: string; b: string; aToB: boolean; bToA: boolean; connections: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> }>();
+		edges.forEach(edge => {
+			if (!edge.source || !edge.target || edge.source === edge.target) return;
+			if (!positionById[edge.source] || !positionById[edge.target]) return;
+			
+			const a = edge.source < edge.target ? edge.source : edge.target;
+			const b = edge.source < edge.target ? edge.target : edge.source;
+			const key = `${a}|${b}`;
+			const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false, connections: [] };
+			
+			if (edge.source === a) entry.aToB = true;
+			else entry.bToA = true;
+
+			const ip = edge.sendBackIp || edge.sendBackMultiaddr?.ip_address || '?';
+			const ifaceInfo = getInterfaceLabel(edge.source, ip);
+			entry.connections.push({
+				from: edge.source,
+				to: edge.target,
+				ip,
+				ifaceLabel: ifaceInfo.label,
+				missingIface: ifaceInfo.missing
+			});
+			pairMap.set(key, entry);
+		});
+
+		pairMap.forEach(entry => {
+			const posA = positionById[entry.a];
+			const posB = positionById[entry.b];
+			if (!posA || !posB) return;
+
+			// Base dashed line
+			linksGroup.append('line')
+				.attr('x1', posA.x)
+				.attr('y1', posA.y)
+				.attr('x2', posB.x)
+				.attr('y2', posB.y)
+				.attr('class', 'graph-link');
+
+			// Calculate midpoint and direction for arrows
+			const dx = posB.x - posA.x;
+			const dy = posB.y - posA.y;
+			const len = Math.hypot(dx, dy) || 1;
+			const ux = dx / len;
+			const uy = dy / len;
+			const mx = (posA.x + posB.x) / 2;
+			const my = (posA.y + posB.y) / 2;
+			const tipOffset = 16; // Distance from center for arrow tips
+			const carrier = 2; // Short segment length for arrow orientation
+
+			// Arrow A -> B (if connection exists in that direction)
+			if (entry.aToB) {
+				const tipX = mx - ux * tipOffset;
+				const tipY = my - uy * tipOffset;
+				arrowsGroup.append('line')
+					.attr('x1', tipX - ux * carrier)
+					.attr('y1', tipY - uy * carrier)
+					.attr('x2', tipX)
+					.attr('y2', tipY)
+					.attr('stroke', 'none')
+					.attr('fill', 'none')
+					.attr('marker-end', 'url(#arrowhead)');
+			}
+
+			// Arrow B -> A (if connection exists in that direction)
+			if (entry.bToA) {
+				const tipX = mx + ux * tipOffset;
+				const tipY = my + uy * tipOffset;
+				arrowsGroup.append('line')
+					.attr('x1', tipX + ux * carrier)
+					.attr('y1', tipY + uy * carrier)
+					.attr('x2', tipX)
+					.attr('y2', tipY)
+					.attr('stroke', 'none')
+					.attr('fill', 'none')
+					.attr('marker-end', 'url(#arrowhead)');
+			}
+
+			if (debugEnabled && entry.connections.length > 0) {
+				const maxBoxes = 6;
+				const fontSize = isMinimized ? 8 : 9;
+				const lineGap = 2;
+				const labelOffsetOut = Math.max(140, minDimension * 0.38);
+				const labelOffsetSide = isMinimized ? 16 : 20;
+				const boxWidth = 170;
+				const maxLineLen = 26;
+
+				const connections = entry.connections.slice(0, maxBoxes);
+				if (entry.connections.length > maxBoxes) {
+					const remaining = entry.connections.length - maxBoxes;
+					connections.push({
+						from: '',
+						to: '',
+						ip: `(+${remaining} more)`,
+						ifaceLabel: '',
+						missingIface: false
+					});
+				}
+
+				let dirX = mx - centerX;
+				let dirY = my - centerY;
+				const dirLen = Math.hypot(dirX, dirY);
+				if (dirLen < 1) {
+					dirX = -uy;
+					dirY = ux;
+				} else {
+					dirX /= dirLen;
+					dirY /= dirLen;
+				}
+
+				const nx = -dirY;
+				const ny = dirX;
+
+				const labelXRaw = mx + dirX * labelOffsetOut + nx * labelOffsetSide;
+				const labelYRaw = my + dirY * labelOffsetOut + ny * labelOffsetSide;
+				const clampPad = Math.min(120, minDimension * 0.12);
+				const labelX = Math.max(clampPad, Math.min(width - clampPad, labelXRaw));
+				const labelY = Math.max(clampPad, Math.min(height - clampPad, labelYRaw));
+
+				const labelGroup = debugLabelsGroup.append('g')
+					.attr('transform', `translate(${labelX}, ${labelY})`);
+
+				const textGroup = labelGroup.append('g');
+
+				connections.forEach((conn, idx) => {
+					const rawLines = conn.from && conn.to
+						? [
+							`${getNodeLabel(conn.from)}→${getNodeLabel(conn.to)}`,
+							`${conn.ip}`,
+							`${conn.ifaceLabel}`
+						]
+						: [conn.ip];
+
+					const wrapped = rawLines.flatMap(line => wrapLine(line, maxLineLen));
+
+					wrapped.forEach((line, lineIdx) => {
+						textGroup.append('text')
+							.attr('x', 0)
+							.attr('y', (idx * (wrapped.length * (fontSize + lineGap))) + lineIdx * (fontSize + lineGap))
+							.attr('text-anchor', 'middle')
+							.attr('dominant-baseline', 'hanging')
+							.attr('font-size', fontSize)
+							.attr('font-family', 'SF Mono, monospace')
+							.attr('fill', conn.missingIface ? 'rgba(248,113,113,0.9)' : 'rgba(255,255,255,0.9)')
+							.text(line);
+					});
+				});
+
+				const bbox = textGroup.node()?.getBBox();
+				if (bbox) {
+					const paddedWidth = Math.max(boxWidth, bbox.width + 14);
+					const boxHeight = bbox.height + 8;
+					const boxMinX = labelX - paddedWidth / 2;
+					const boxMaxX = labelX + paddedWidth / 2;
+					const boxMinY = labelY + bbox.y - 4;
+					const boxMaxY = boxMinY + boxHeight;
+
+					const clampPadDynamic = Math.min(140, minDimension * 0.18);
+					let shiftX = 0;
+					let shiftY = 0;
+					if (boxMinX < clampPadDynamic) shiftX = clampPadDynamic - boxMinX;
+					if (boxMaxX > width - clampPadDynamic) shiftX = (width - clampPadDynamic) - boxMaxX;
+					if (boxMinY < clampPadDynamic) shiftY = clampPadDynamic - boxMinY;
+					if (boxMaxY > height - clampPadDynamic) shiftY = (height - clampPadDynamic) - boxMaxY;
+
+					const finalX = labelX + shiftX;
+					const finalY = labelY + shiftY;
+					labelGroup.attr('transform', `translate(${finalX}, ${finalY})`);
+
+					labelGroup.insert('rect', 'g')
+						.attr('x', -paddedWidth / 2)
+						.attr('y', bbox.y - 4)
+						.attr('width', paddedWidth)
+						.attr('height', boxHeight)
+						.attr('rx', 4)
+						.attr('fill', 'rgba(0,0,0,0.75)')
+						.attr('stroke', 'rgba(255,255,255,0.12)')
+						.attr('stroke-width', 0.6);
+				}
+			}
+		});
+
+		// Draw nodes
+		const nodesGroup = svg.append('g').attr('class', 'nodes-group');
+
+		nodesWithPositions.forEach(nodeInfo => {
+			const node = nodeInfo.data;
+			const macmon = node.macmon_info;
+			const modelId = node.system_info?.model_id || 'Unknown';
+			const friendlyName = node.friendly_name || modelId;
+
+			let ramUsagePercent = 0;
+			let gpuTemp = NaN;
+			let ramTotal = 0;
+			let ramUsed = 0;
+			let gpuUsagePercent = 0;
+			let sysPower: number | null = null;
+
+			if (macmon) {
+				if (macmon.memory && macmon.memory.ram_total > 0) {
+					ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
+					ramTotal = macmon.memory.ram_total;
+					ramUsed = macmon.memory.ram_usage;
+				}
+				if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
+					gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
+				}
+				if (macmon.gpu_usage) {
+					gpuUsagePercent = macmon.gpu_usage[1] * 100;
+				}
+				if (macmon.sys_power) {
+					sysPower = macmon.sys_power;
+				}
+			}
+
+			const nodeG = nodesGroup.append('g')
+				.attr('class', 'graph-node')
+				.style('cursor', 'pointer');
+
+			// Add tooltip
+			nodeG.append('title')
+				.text(`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`);
+
+			let iconBaseWidth = nodeRadius * 1.2;
+			let iconBaseHeight = nodeRadius * 1.0;
+			const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+
+			const modelLower = modelId.toLowerCase();
+			
+			// Check if this node should be highlighted (from hovered instance)
+			const isHighlighted = highlightedNodes.has(nodeInfo.id);
+			
+		// Holographic wireframe colors - yellow border when highlighted
+		const wireColor = isHighlighted ? 'rgba(255,215,0,0.9)' : 'rgba(179,179,179,0.8)';
+		const wireColorBright = 'rgba(255,255,255,0.9)';
+		const fillColor = isHighlighted ? 'rgba(255,215,0,0.15)' : 'rgba(255,215,0,0.08)';
+		const strokeWidth = isHighlighted ? 2.5 : 1.5;
+			const screenFill = 'rgba(0,20,40,0.9)';
+			const glowColor = 'rgba(255,215,0,0.3)';
+
+			if (modelLower === 'mac studio') {
+				// Mac Studio - classic cube with memory fill
+				iconBaseWidth = nodeRadius * 1.25;
+				iconBaseHeight = nodeRadius * 0.85;
+				const x = nodeInfo.x - iconBaseWidth / 2;
+				const y = nodeInfo.y - iconBaseHeight / 2;
+				const cornerRadius = 4;
+				const topSurfaceHeight = iconBaseHeight * 0.15;
+
+				// Create clip path for memory fill area (front body)
+				const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+				defs.append('clipPath')
+					.attr('id', studioClipId)
+					.append('rect')
+					.attr('x', x)
+					.attr('y', y + topSurfaceHeight)
+					.attr('width', iconBaseWidth)
+					.attr('height', iconBaseHeight - topSurfaceHeight)
+					.attr('rx', cornerRadius - 1);
+
+				// Main body (uniform color)
+				nodeG.append('rect')
+					.attr('x', x)
+					.attr('y', y)
+					.attr('width', iconBaseWidth)
+					.attr('height', iconBaseHeight)
+					.attr('rx', cornerRadius)
+					.attr('fill', '#1a1a1a')
+					.attr('stroke', wireColor)
+					.attr('stroke-width', strokeWidth);
+
+				// Memory fill (fills from bottom up)
+				if (ramUsagePercent > 0) {
+					const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+					nodeG.append('rect')
+						.attr('x', x)
+						.attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
+						.attr('width', iconBaseWidth)
+						.attr('height', memFillActualHeight)
+						.attr('fill', 'rgba(255,215,0,0.75)')
+						.attr('clip-path', `url(#${studioClipId})`);
+				}
+
+				// Front panel details - vertical slots
+				const detailColor = 'rgba(0,0,0,0.35)';
+				const slotHeight = iconBaseHeight * 0.14;
+				const vSlotWidth = iconBaseWidth * 0.05;
+				const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
+				const vSlot1X = x + iconBaseWidth * 0.18;
+				const vSlot2X = x + iconBaseWidth * 0.28;
+
+				[vSlot1X, vSlot2X].forEach(vx => {
+					nodeG.append('rect')
+						.attr('x', vx - vSlotWidth / 2)
+						.attr('y', vSlotY)
+						.attr('width', vSlotWidth)
+						.attr('height', slotHeight)
+						.attr('fill', detailColor)
+						.attr('rx', 1.5);
+				});
+
+				// Horizontal slot (SD card)
+				const hSlotWidth = iconBaseWidth * 0.2;
+				const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
+				nodeG.append('rect')
+					.attr('x', hSlotX)
+					.attr('y', vSlotY)
+					.attr('width', hSlotWidth)
+					.attr('height', slotHeight * 0.6)
+					.attr('fill', detailColor)
+					.attr('rx', 1);
+
+			} else if (modelLower === 'mac mini') {
+				// Mac Mini - classic flat box with memory fill
+				iconBaseWidth = nodeRadius * 1.3;
+				iconBaseHeight = nodeRadius * 0.7;
+				const x = nodeInfo.x - iconBaseWidth / 2;
+				const y = nodeInfo.y - iconBaseHeight / 2;
+				const cornerRadius = 3;
+				const topSurfaceHeight = iconBaseHeight * 0.20;
+
+				// Create clip path for memory fill area
+				const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+				defs.append('clipPath')
+					.attr('id', miniClipId)
+					.append('rect')
+					.attr('x', x)
+					.attr('y', y + topSurfaceHeight)
+					.attr('width', iconBaseWidth)
+					.attr('height', iconBaseHeight - topSurfaceHeight)
+					.attr('rx', cornerRadius - 1);
+
+				// Main body (uniform color)
+				nodeG.append('rect')
+					.attr('x', x)
+					.attr('y', y)
+					.attr('width', iconBaseWidth)
+					.attr('height', iconBaseHeight)
+					.attr('rx', cornerRadius)
+					.attr('fill', '#1a1a1a')
+					.attr('stroke', wireColor)
+					.attr('stroke-width', strokeWidth);
+
+				// Memory fill (fills from bottom up)
+				if (ramUsagePercent > 0) {
+					const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+					nodeG.append('rect')
+						.attr('x', x)
+						.attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
+						.attr('width', iconBaseWidth)
+						.attr('height', memFillActualHeight)
+						.attr('fill', 'rgba(255,215,0,0.75)')
+						.attr('clip-path', `url(#${miniClipId})`);
+				}
+
+				// Front panel details - vertical slots (no horizontal slot for Mini)
+				const detailColor = 'rgba(0,0,0,0.35)';
+				const slotHeight = iconBaseHeight * 0.20;
+				const vSlotWidth = iconBaseWidth * 0.045;
+				const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
+				const vSlot1X = x + iconBaseWidth * 0.20;
+				const vSlot2X = x + iconBaseWidth * 0.30;
+
+				[vSlot1X, vSlot2X].forEach(vx => {
+					nodeG.append('rect')
+						.attr('x', vx - vSlotWidth / 2)
+						.attr('y', vSlotY)
+						.attr('width', vSlotWidth)
+						.attr('height', slotHeight)
+						.attr('fill', detailColor)
+						.attr('rx', 1.2);
+				});
+
+			} else if (modelLower === 'macbook pro' || modelLower.includes('macbook')) {
+				// MacBook Pro - classic style with memory fill on screen
+				iconBaseWidth = nodeRadius * 1.6;
+				iconBaseHeight = nodeRadius * 1.15;
+				const x = nodeInfo.x - iconBaseWidth / 2;
+				const y = nodeInfo.y - iconBaseHeight / 2;
+
+				const screenHeight = iconBaseHeight * 0.70;
+				const baseHeight = iconBaseHeight * 0.30;
+				const screenWidth = iconBaseWidth * 0.85;
+				const screenX = nodeInfo.x - screenWidth / 2;
+				const screenBezel = 3;
+
+				// Create clip path for screen content
+				const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
+				defs.append('clipPath')
+					.attr('id', screenClipId)
+					.append('rect')
+					.attr('x', screenX + screenBezel)
+					.attr('y', y + screenBezel)
+					.attr('width', screenWidth - screenBezel * 2)
+					.attr('height', screenHeight - screenBezel * 2)
+					.attr('rx', 2);
+
+				// Screen outer frame
+				nodeG.append('rect')
+					.attr('x', screenX)
+					.attr('y', y)
+					.attr('width', screenWidth)
+					.attr('height', screenHeight)
+					.attr('rx', 3)
+					.attr('fill', '#1a1a1a')
+					.attr('stroke', wireColor)
+					.attr('stroke-width', strokeWidth);
+
+				// Screen inner (dark background)
+				nodeG.append('rect')
+					.attr('x', screenX + screenBezel)
+					.attr('y', y + screenBezel)
+					.attr('width', screenWidth - screenBezel * 2)
+					.attr('height', screenHeight - screenBezel * 2)
+					.attr('rx', 2)
+					.attr('fill', '#0a0a12');
+
+				// Memory fill on screen (fills from bottom up - classic style)
+				if (ramUsagePercent > 0) {
+					const memFillTotalHeight = screenHeight - screenBezel * 2;
+					const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
+					nodeG.append('rect')
+						.attr('x', screenX + screenBezel)
+						.attr('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight))
+						.attr('width', screenWidth - screenBezel * 2)
+						.attr('height', memFillActualHeight)
+						.attr('fill', 'rgba(255,215,0,0.85)')
+						.attr('clip-path', `url(#${screenClipId})`);
+				}
+
+				// Apple logo on screen (centered, on top of memory fill)
+				const targetLogoHeight = screenHeight * 0.22;
+				const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
+				const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
+				const logoY = y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2);
+				nodeG.append('path')
+					.attr('d', APPLE_LOGO_PATH)
+					.attr('transform', `translate(${logoX}, ${logoY}) scale(${logoScale})`)
+					.attr('fill', '#FFFFFF')
+					.attr('opacity', 0.9);
+
+				// Base (keyboard) - trapezoidal
+				const baseY = y + screenHeight;
+				const baseTopWidth = screenWidth;
+				const baseBottomWidth = iconBaseWidth;
+				const baseTopX = nodeInfo.x - baseTopWidth / 2;
+				const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
+
+				nodeG.append('path')
+					.attr('d', `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`)
+					.attr('fill', '#2c2c2c')
+					.attr('stroke', wireColor)
+					.attr('stroke-width', 1);
+
+				// Keyboard area
+				const keyboardX = baseTopX + 6;
+				const keyboardY = baseY + 3;
+				const keyboardWidth = baseTopWidth - 12;
+				const keyboardHeight = baseHeight * 0.55;
+				nodeG.append('rect')
+					.attr('x', keyboardX)
+					.attr('y', keyboardY)
+					.attr('width', keyboardWidth)
+					.attr('height', keyboardHeight)
+					.attr('fill', 'rgba(0,0,0,0.2)')
+					.attr('rx', 2);
+
+				// Trackpad
+				const trackpadWidth = baseTopWidth * 0.4;
+				const trackpadX = nodeInfo.x - trackpadWidth / 2;
+				const trackpadY = baseY + keyboardHeight + 5;
+				const trackpadHeight = baseHeight * 0.30;
+				nodeG.append('rect')
+					.attr('x', trackpadX)
+					.attr('y', trackpadY)
+					.attr('width', trackpadWidth)
+					.attr('height', trackpadHeight)
+					.attr('fill', 'rgba(255,255,255,0.08)')
+					.attr('rx', 2);
+
+			} else {
+				// Default/Unknown - holographic hexagon
+				const hexRadius = nodeRadius * 0.6;
+				const hexPoints = Array.from({ length: 6 }, (_, i) => {
+					const angle = (i * 60 - 30) * Math.PI / 180;
+					return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
+				}).join(' ');
+
+				// Main shape
+				nodeG.append('polygon')
+					.attr('points', hexPoints)
+					.attr('fill', fillColor)
+					.attr('stroke', wireColor)
+					.attr('stroke-width', strokeWidth);
+			}
+
+			// --- Vertical GPU Bar (right side of icon) ---
+			// Show in both full mode and minimized mode (scaled appropriately)
+			if (showFullLabels || isMinimized) {
+				const gpuBarWidth = isMinimized ? Math.max(16, nodeRadius * 0.32) : Math.max(28, nodeRadius * 0.30);
+				const gpuBarHeight = iconBaseHeight * 0.95;
+				const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
+				const gpuBarX = nodeInfo.x + barXOffset;
+				const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
+
+				// GPU Bar Background (grey, no border)
+				nodeG.append('rect')
+					.attr('x', gpuBarX)
+					.attr('y', gpuBarY)
+					.attr('width', gpuBarWidth)
+					.attr('height', gpuBarHeight)
+					.attr('fill', 'rgba(80, 80, 90, 0.7)')
+					.attr('rx', 2);
+
+				// GPU Bar Fill (from bottom up, colored by temperature)
+				if (gpuUsagePercent > 0) {
+					const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
+					const gpuFillColor = getTemperatureColor(gpuTemp);
+					nodeG.append('rect')
+						.attr('x', gpuBarX)
+						.attr('y', gpuBarY + (gpuBarHeight - fillHeight))
+						.attr('width', gpuBarWidth)
+						.attr('height', fillHeight)
+						.attr('fill', gpuFillColor)
+						.attr('opacity', 0.9)
+						.attr('rx', 2);
+				}
+
+				// GPU Stats Text (centered on bar, multiline, bigger and bold)
+				const gpuTextX = gpuBarX + gpuBarWidth / 2;
+				const gpuTextY = gpuBarY + gpuBarHeight / 2;
+				const gpuTextFontSize = isMinimized ? Math.max(10, gpuBarWidth * 0.6) : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
+				const lineSpacing = gpuTextFontSize * 1.25;
+
+				const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
+				const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : '-';
+				const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : '-';
+
+				// GPU Usage %
+				nodeG.append('text')
+					.attr('x', gpuTextX)
+					.attr('y', gpuTextY - lineSpacing)
+					.attr('text-anchor', 'middle')
+					.attr('dominant-baseline', 'middle')
+					.attr('fill', '#FFFFFF')
+					.attr('font-size', gpuTextFontSize)
+					.attr('font-weight', '700')
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(gpuUsageText);
+
+				// Temperature
+				nodeG.append('text')
+					.attr('x', gpuTextX)
+					.attr('y', gpuTextY)
+					.attr('text-anchor', 'middle')
+					.attr('dominant-baseline', 'middle')
+					.attr('fill', '#FFFFFF')
+					.attr('font-size', gpuTextFontSize)
+					.attr('font-weight', '700')
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(tempText);
+
+				// Power (Watts)
+				nodeG.append('text')
+					.attr('x', gpuTextX)
+					.attr('y', gpuTextY + lineSpacing)
+					.attr('text-anchor', 'middle')
+					.attr('dominant-baseline', 'middle')
+					.attr('fill', '#FFFFFF')
+					.attr('font-size', gpuTextFontSize)
+					.attr('font-weight', '700')
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(powerText);
+			}
+
+			// Labels - adapt based on mode
+			if (showFullLabels) {
+				// FULL MODE: Name above, memory info below (1-4 nodes)
+				const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
+				const fontSize = Math.max(10, nodeRadius * 0.16);
+				
+				// Truncate name based on node count
+				const maxNameLen = numNodes === 1 ? 22 : (numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14);
+				const displayName = friendlyName.length > maxNameLen 
+					? friendlyName.slice(0, maxNameLen - 2) + '..'
+					: friendlyName;
+				
+				// Name label above
+				nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', nameY)
+					.attr('text-anchor', 'middle')
+					.attr('dominant-baseline', 'middle')
+					.attr('fill', '#FFD700')
+					.attr('font-size', fontSize)
+					.attr('font-weight', 500)
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(displayName);
+
+				// Memory info below - used in grey, total in yellow
+				const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
+				const memText = nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', infoY)
+					.attr('text-anchor', 'middle')
+					.attr('font-size', fontSize * 0.85)
+					.attr('font-family', 'SF Mono, Monaco, monospace');
+				memText.append('tspan')
+					.attr('fill', 'rgba(255,215,0,0.9)')
+					.text(`${formatBytes(ramUsed)}`);
+				memText.append('tspan')
+					.attr('fill', 'rgba(179,179,179,0.9)')
+					.text(`/${formatBytes(ramTotal)}`);
+				memText.append('tspan')
+					.attr('fill', 'rgba(179,179,179,0.7)')
+					.text(` (${ramUsagePercent.toFixed(0)}%)`);
+
+			} else if (showCompactLabels) {
+				// COMPACT MODE: Just name and basic info (4+ nodes)
+				const fontSize = Math.max(7, nodeRadius * 0.11);
+				
+				// Very compact name below icon
+				const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
+				const shortName = friendlyName.length > 10 
+					? friendlyName.slice(0, 8) + '..'
+					: friendlyName;
+				nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', nameY)
+					.attr('text-anchor', 'middle')
+					.attr('fill', '#FFD700')
+					.attr('font-size', fontSize)
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(shortName);
+
+				// Single line of key stats
+				const statsY = nameY + 9;
+				nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', statsY)
+					.attr('text-anchor', 'middle')
+					.attr('fill', 'rgba(255,215,0,0.7)')
+					.attr('font-size', fontSize * 0.85)
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(`${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? ' ' + gpuTemp.toFixed(0) + '°C' : ''}`);
+
+			} else {
+				// MINIMIZED MODE: Show name above and memory info below (like main topology)
+				const fontSize = 8;
+				
+				// Friendly name (shortened) above icon
+				const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
+				const shortName = friendlyName.length > 12 
+					? friendlyName.slice(0, 10) + '..'
+					: friendlyName;
+				nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', nameY)
+					.attr('text-anchor', 'middle')
+					.attr('fill', '#FFD700')
+					.attr('font-size', fontSize)
+					.attr('font-weight', '500')
+					.attr('font-family', 'SF Mono, Monaco, monospace')
+					.text(shortName);
+
+				// Memory info below icon - used in grey, total in yellow (same as main topology)
+				const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
+				const memTextMini = nodeG.append('text')
+					.attr('x', nodeInfo.x)
+					.attr('y', infoY)
+					.attr('text-anchor', 'middle')
+					.attr('font-size', fontSize * 0.85)
+					.attr('font-family', 'SF Mono, Monaco, monospace');
+				memTextMini.append('tspan')
+					.attr('fill', 'rgba(255,215,0,0.9)')
+					.text(`${formatBytes(ramUsed)}`);
+				memTextMini.append('tspan')
+					.attr('fill', 'rgba(179,179,179,0.9)')
+					.text(`/${formatBytes(ramTotal)}`);
+				memTextMini.append('tspan')
+					.attr('fill', 'rgba(179,179,179,0.7)')
+					.text(` (${ramUsagePercent.toFixed(0)}%)`);
+			}
+		});
+
+	}
+
+	$effect(() => {
+		if (data) {
+			renderGraph();
+		}
+	});
+
+	onMount(() => {
+		if (svgContainer) {
+			resizeObserver = new ResizeObserver(() => {
+				renderGraph();
+			});
+			resizeObserver.observe(svgContainer);
+		}
+	});
+
+	onDestroy(() => {
+		resizeObserver?.disconnect();
+	});
+</script>
+
+<svg 
+	bind:this={svgContainer}
+	class="w-full h-full {className}"
+></svg>
+
+<style>
+	:global(.graph-node) {
+		transition: transform 0.2s ease, opacity 0.2s ease;
+	}
+	:global(.graph-node:hover) {
+		filter: brightness(1.1);
+	}
+	:global(.graph-link) {
+		stroke: var(--exo-light-gray, #B3B3B3);
+		stroke-width: 1px;
+		stroke-dasharray: 4, 4;
+		opacity: 0.8;
+		animation: flowAnimation 0.75s linear infinite;
+	}
+	@keyframes flowAnimation {
+		from { stroke-dashoffset: 0; }
+		to { stroke-dashoffset: -10; }
+	}
+</style>
diff --git a/dashboard/src/lib/components/index.ts b/dashboard/src/lib/components/index.ts
new file mode 100644
index 00000000..bd750839
--- /dev/null
+++ b/dashboard/src/lib/components/index.ts
@@ -0,0 +1,7 @@
+export { default as TopologyGraph } from './TopologyGraph.svelte';
+export { default as ChatForm } from './ChatForm.svelte';
+export { default as ChatMessages } from './ChatMessages.svelte';
+export { default as ChatAttachments } from './ChatAttachments.svelte';
+export { default as ChatSidebar } from './ChatSidebar.svelte';
+export { default as ModelCard } from './ModelCard.svelte';
+
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
new file mode 100644
index 00000000..ffeb1aa1
--- /dev/null
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -0,0 +1,1395 @@
+/**
+ * AppStore - Central state management for the EXO dashboard
+ * 
+ * Manages:
+ * - Chat state (whether a conversation has started)
+ * - Topology data from the EXO server
+ * - UI state for the topology/chat transition
+ */
+
+import { browser } from '$app/environment';
+
+// UUID generation fallback for browsers without crypto.randomUUID
+function generateUUID(): string {
+	if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+		return crypto.randomUUID();
+	}
+	// Fallback implementation
+	return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+		const r = Math.random() * 16 | 0;
+		const v = c === 'x' ? r : (r & 0x3 | 0x8);
+		return v.toString(16);
+	});
+}
+
+export interface NodeInfo {
+	system_info?: {
+		model_id?: string;
+		chip?: string;
+		memory?: number;
+	};
+	network_interfaces?: Array<{
+		name?: string;
+		addresses?: string[];
+	}>;
+	ip_to_interface?: Record<string, string>;
+	macmon_info?: {
+		memory?: {
+			ram_usage: number;
+			ram_total: number;
+		};
+		temp?: {
+			gpu_temp_avg: number;
+		};
+		gpu_usage?: [number, number];
+		sys_power?: number;
+	};
+	last_macmon_update: number;
+	friendly_name?: string;
+}
+
+export interface TopologyEdge {
+	source: string;
+	target: string;
+	sendBackIp?: string;
+	sendBackInterface?: string;
+}
+
+export interface TopologyData {
+	nodes: Record<string, NodeInfo>;
+	edges: TopologyEdge[];
+}
+
+export interface Instance {
+	shardAssignments?: {
+		modelId?: string;
+		runnerToShard?: Record<string, unknown>;
+		nodeToRunner?: Record<string, string>;
+	};
+}
+
+interface RawNodeProfile {
+	modelId?: string;
+	chipId?: string;
+	friendlyName?: string;
+	networkInterfaces?: Array<{
+		name?: string;
+		ipAddress?: string;
+		addresses?: Array<{ address?: string } | string>;
+		ipv4?: string;
+		ipv6?: string;
+		ipAddresses?: string[];
+		ips?: string[];
+	}>;
+	memory?: {
+		ramTotal?: { inBytes: number };
+		ramAvailable?: { inBytes: number };
+		swapTotal?: { inBytes: number };
+		swapAvailable?: { inBytes: number };
+	};
+	system?: {
+		gpuUsage?: number;
+		temp?: number;
+		sysPower?: number;
+	};
+}
+
+interface RawTopologyNode {
+	nodeId: string;
+	nodeProfile: RawNodeProfile;
+}
+
+interface RawTopologyConnection {
+	localNodeId: string;
+	sendBackNodeId: string;
+	sendBackMultiaddr?: { multiaddr?: string; address?: string; ip_address?: string } | string;
+}
+
+interface RawTopology {
+	nodes: RawTopologyNode[];
+	connections?: RawTopologyConnection[];
+}
+
+type RawNodeProfiles = Record<string, RawNodeProfile>;
+
+export interface DownloadProgress {
+	totalBytes: number;
+	downloadedBytes: number;
+	speed: number;
+	etaMs: number;
+	percentage: number;
+	completedFiles: number;
+	totalFiles: number;
+	files: Array<{
+		name: string;
+		totalBytes: number;
+		downloadedBytes: number;
+		speed: number;
+		etaMs: number;
+		percentage: number;
+	}>;
+}
+
+export interface ModelDownloadStatus {
+	isDownloading: boolean;
+	progress: DownloadProgress | null;
+	nodeDetails: Array<{
+		nodeId: string;
+		nodeName: string;
+		progress: DownloadProgress;
+	}>;
+}
+
+// Placement preview from the API
+export interface PlacementPreview {
+	model_id: string;
+	sharding: 'Pipeline' | 'Tensor';
+	instance_meta: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+	instance: unknown | null;
+	memory_delta_by_node: Record<string, number> | null;
+	error: string | null;
+}
+
+export interface PlacementPreviewResponse {
+	previews: PlacementPreview[];
+}
+
+interface RawStateResponse {
+	topology?: RawTopology;
+	instances?: Record<string, { MlxRingInstance?: Instance; MlxIbvInstance?: Instance; MlxJacclInstance?: Instance }>;
+	runners?: Record<string, unknown>;
+	downloads?: Record<string, unknown[]>;
+	nodeProfiles?: RawNodeProfiles;
+}
+
+export interface MessageAttachment {
+	type: 'image' | 'text' | 'file';
+	name: string;
+	content?: string;
+	preview?: string;
+	mimeType?: string;
+}
+
+export interface Message {
+	id: string;
+	role: 'user' | 'assistant' | 'system';
+	content: string;
+	timestamp: number;
+	thinking?: string;
+	attachments?: MessageAttachment[];
+	ttftMs?: number;  // Time to first token in ms (for assistant messages)
+	tps?: number;     // Tokens per second (for assistant messages)
+}
+
+export interface Conversation {
+	id: string;
+	name: string;
+	messages: Message[];
+	createdAt: number;
+	updatedAt: number;
+	modelId: string | null;
+	sharding: string | null;
+	instanceType: string | null;
+}
+
+const STORAGE_KEY = 'exo-conversations';
+
+function transformTopology(raw: RawTopology, profiles?: RawNodeProfiles): TopologyData {
+	const nodes: Record<string, NodeInfo> = {};
+	const edges: TopologyEdge[] = [];
+
+	for (const node of raw.nodes || []) {
+		const mergedProfile = profiles?.[node.nodeId];
+		const profile = { ...(node.nodeProfile ?? {}), ...(mergedProfile ?? {}) };
+		const ramTotal = profile?.memory?.ramTotal?.inBytes ?? 0;
+		const ramAvailable = profile?.memory?.ramAvailable?.inBytes ?? 0;
+		const ramUsage = Math.max(ramTotal - ramAvailable, 0);
+
+		const networkInterfaces = (profile?.networkInterfaces || []).map((iface) => {
+			const addresses: string[] = [];
+			if (iface.ipAddress && typeof iface.ipAddress === 'string') {
+				addresses.push(iface.ipAddress);
+			}
+			if (Array.isArray(iface.addresses)) {
+				for (const addr of iface.addresses) {
+					if (typeof addr === 'string') addresses.push(addr);
+					else if (addr && typeof addr === 'object' && addr.address) addresses.push(addr.address);
+				}
+			}
+			if (Array.isArray(iface.ipAddresses)) {
+				addresses.push(...iface.ipAddresses.filter((a): a is string => typeof a === 'string'));
+			}
+			if (Array.isArray(iface.ips)) {
+				addresses.push(...iface.ips.filter((a): a is string => typeof a === 'string'));
+			}
+			if (iface.ipv4 && typeof iface.ipv4 === 'string') addresses.push(iface.ipv4);
+			if (iface.ipv6 && typeof iface.ipv6 === 'string') addresses.push(iface.ipv6);
+
+			return {
+				name: iface.name,
+				addresses: Array.from(new Set(addresses))
+			};
+		});
+
+		const ipToInterface: Record<string, string> = {};
+		for (const iface of networkInterfaces) {
+			for (const addr of iface.addresses || []) {
+				ipToInterface[addr] = iface.name ?? '';
+			}
+		}
+
+		nodes[node.nodeId] = {
+			system_info: {
+				model_id: profile?.modelId ?? 'Unknown',
+				chip: profile?.chipId,
+				memory: ramTotal
+			},
+			network_interfaces: networkInterfaces,
+			ip_to_interface: ipToInterface,
+			macmon_info: {
+				memory: {
+					ram_usage: ramUsage,
+					ram_total: ramTotal
+				},
+				temp: profile?.system?.temp !== undefined ? { gpu_temp_avg: profile.system.temp } : undefined,
+				gpu_usage: profile?.system?.gpuUsage !== undefined ? [0, profile.system.gpuUsage] : undefined,
+				sys_power: profile?.system?.sysPower
+			},
+			last_macmon_update: Date.now() / 1000,
+			friendly_name: profile?.friendlyName
+		};
+	}
+
+	for (const conn of raw.connections || []) {
+		if (!conn.localNodeId || !conn.sendBackNodeId) continue;
+		if (conn.localNodeId === conn.sendBackNodeId) continue;
+		if (!nodes[conn.localNodeId] || !nodes[conn.sendBackNodeId]) continue;
+
+		let sendBackIp: string | undefined;
+		if (conn.sendBackMultiaddr) {
+			const multi = conn.sendBackMultiaddr;
+			if (typeof multi === 'string') {
+				sendBackIp = extractIpFromMultiaddr(multi);
+			} else {
+				sendBackIp = multi.ip_address || extractIpFromMultiaddr(multi.multiaddr) || extractIpFromMultiaddr(multi.address);
+			}
+		}
+
+		edges.push({
+			source: conn.localNodeId,
+			target: conn.sendBackNodeId,
+			sendBackIp
+		});
+	}
+
+	return { nodes, edges };
+}
+
+function extractIpFromMultiaddr(ma?: string): string | undefined {
+	if (!ma) return undefined;
+	const parts = ma.split('/');
+	const ip4Idx = parts.indexOf('ip4');
+	const ip6Idx = parts.indexOf('ip6');
+	const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
+	if (idx >= 0 && parts.length > idx + 1) {
+		return parts[idx + 1];
+	}
+	return undefined;
+}
+
+class AppStore {
+	// Conversation state
+	conversations = $state<Conversation[]>([]);
+	activeConversationId = $state<string | null>(null);
+	
+	// Chat state
+	hasStartedChat = $state(false);
+	messages = $state<Message[]>([]);
+	currentResponse = $state('');
+	isLoading = $state(false);
+	
+	// Performance metrics
+	ttftMs = $state<number | null>(null);  // Time to first token in ms
+	tps = $state<number | null>(null);     // Tokens per second
+	totalTokens = $state<number>(0);       // Total tokens in current response
+	
+	// Topology state
+	topologyData = $state<TopologyData | null>(null);
+	instances = $state<Record<string, unknown>>({});
+	runners = $state<Record<string, unknown>>({});
+	downloads = $state<Record<string, unknown[]>>({});
+	placementPreviews = $state<PlacementPreview[]>([]);
+	selectedPreviewModelId = $state<string | null>(null);
+	isLoadingPreviews = $state(false);
+	lastUpdate = $state<number | null>(null);
+	
+	// UI state
+	isTopologyMinimized = $state(false);
+	isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
+	debugMode = $state(false);
+	
+	private fetchInterval: ReturnType<typeof setInterval> | null = null;
+	private previewsInterval: ReturnType<typeof setInterval> | null = null;
+	private lastConversationPersistTs = 0;
+
+	constructor() {
+		if (browser) {
+			this.startPolling();
+			this.loadConversationsFromStorage();
+			this.loadDebugModeFromStorage();
+		}
+	}
+
+	/**
+	 * Load conversations from localStorage
+	 */
+	private loadConversationsFromStorage() {
+		try {
+			const stored = localStorage.getItem(STORAGE_KEY);
+			if (stored) {
+				const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
+				this.conversations = parsed.map((conversation) => ({
+					id: conversation.id ?? generateUUID(),
+					name: conversation.name ?? 'Chat',
+					messages: conversation.messages ?? [],
+					createdAt: conversation.createdAt ?? Date.now(),
+					updatedAt: conversation.updatedAt ?? Date.now(),
+					modelId: conversation.modelId ?? null,
+					sharding: conversation.sharding ?? null,
+					instanceType: conversation.instanceType ?? null
+				}));
+			}
+		} catch (error) {
+			console.error('Failed to load conversations:', error);
+		}
+	}
+
+	/**
+	 * Save conversations to localStorage
+	 */
+	private saveConversationsToStorage() {
+		try {
+			localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
+		} catch (error) {
+			console.error('Failed to save conversations:', error);
+		}
+	}
+
+	private loadDebugModeFromStorage() {
+		try {
+			const stored = localStorage.getItem('exo-debug-mode');
+			if (stored !== null) {
+				this.debugMode = stored === 'true';
+			}
+		} catch (error) {
+			console.error('Failed to load debug mode:', error);
+		}
+	}
+
+	private saveDebugModeToStorage() {
+		try {
+			localStorage.setItem('exo-debug-mode', this.debugMode ? 'true' : 'false');
+		} catch (error) {
+			console.error('Failed to save debug mode:', error);
+		}
+	}
+
+	/**
+	 * Create a new conversation
+	 */
+	createConversation(name?: string): string {
+		const id = generateUUID();
+		const now = Date.now();
+
+		// Try to derive model and strategy immediately from selected model or running instances
+		let derivedModelId = this.selectedChatModel || null;
+		let derivedInstanceType: string | null = null;
+		let derivedSharding: string | null = null;
+
+		// If no selected model, fall back to the first running instance
+		if (!derivedModelId) {
+			const firstInstance = Object.values(this.instances)[0];
+			if (firstInstance) {
+				const candidateModel = this.extractInstanceModelId(firstInstance);
+				derivedModelId = candidateModel ?? null;
+				const details = this.describeInstance(firstInstance);
+				derivedInstanceType = details.instanceType;
+				derivedSharding = details.sharding;
+			}
+		} else {
+			// If selected model is set, attempt to get its details from instances
+			for (const [, instanceWrapper] of Object.entries(this.instances)) {
+				const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+				if (candidateModelId === derivedModelId) {
+					const details = this.describeInstance(instanceWrapper);
+					derivedInstanceType = details.instanceType;
+					derivedSharding = details.sharding;
+					break;
+				}
+			}
+		}
+
+		const conversation: Conversation = {
+			id,
+			name: name || `Chat ${new Date(now).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
+			messages: [],
+			createdAt: now,
+			updatedAt: now,
+			modelId: derivedModelId,
+			sharding: derivedSharding,
+			instanceType: derivedInstanceType
+		};
+		
+		this.conversations.unshift(conversation);
+		this.activeConversationId = id;
+		this.messages = [];
+		this.hasStartedChat = true;
+		this.isTopologyMinimized = true;
+		this.isSidebarOpen = true; // Auto-open sidebar when chatting
+		
+		this.saveConversationsToStorage();
+		return id;
+	}
+
+	/**
+	 * Load a conversation by ID
+	 */
+	loadConversation(id: string): boolean {
+		const conversation = this.conversations.find(c => c.id === id);
+		if (!conversation) return false;
+		
+		this.activeConversationId = id;
+		this.messages = [...conversation.messages];
+		this.hasStartedChat = true;
+		this.isTopologyMinimized = true;
+		this.isSidebarOpen = true; // Auto-open sidebar when chatting
+		this.refreshConversationModelFromInstances();
+		
+		return true;
+	}
+
+	/**
+	 * Delete a conversation by ID
+	 */
+	deleteConversation(id: string) {
+		this.conversations = this.conversations.filter(c => c.id !== id);
+		
+		if (this.activeConversationId === id) {
+			this.activeConversationId = null;
+			this.messages = [];
+			this.hasStartedChat = false;
+			this.isTopologyMinimized = false;
+		}
+		
+		this.saveConversationsToStorage();
+	}
+
+	/**
+	 * Delete all conversations
+	 */
+	deleteAllConversations() {
+		this.conversations = [];
+		this.activeConversationId = null;
+		this.messages = [];
+		this.hasStartedChat = false;
+		this.isTopologyMinimized = false;
+		this.saveConversationsToStorage();
+	}
+
+	/**
+	 * Rename a conversation
+	 */
+	renameConversation(id: string, newName: string) {
+		const conversation = this.conversations.find(c => c.id === id);
+		if (conversation) {
+			conversation.name = newName;
+			conversation.updatedAt = Date.now();
+			this.saveConversationsToStorage();
+		}
+	}
+
+	private getTaggedValue(obj: unknown): [string | null, unknown] {
+		if (!obj || typeof obj !== 'object') return [null, null];
+		const keys = Object.keys(obj as Record<string, unknown>);
+		if (keys.length === 1) {
+			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+		}
+		return [null, null];
+	}
+
+	private extractInstanceModelId(instanceWrapped: unknown): string | null {
+		const [, instance] = this.getTaggedValue(instanceWrapped);
+		if (!instance || typeof instance !== 'object') return null;
+		const inst = instance as { shardAssignments?: { modelId?: string } };
+		return inst.shardAssignments?.modelId ?? null;
+	}
+
+	private describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
+		const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
+		if (!instance || typeof instance !== 'object') {
+			return { sharding: null, instanceType: null };
+		}
+
+		let instanceType: string | null = null;
+		if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+		else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+
+		let sharding: string | null = null;
+		const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+		const firstShardWrapped = Object.values(runnerToShard)[0];
+		if (firstShardWrapped) {
+			const [shardTag] = this.getTaggedValue(firstShardWrapped);
+			if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+			else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+			else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+		}
+
+		return { sharding, instanceType };
+	}
+
+	private buildConversationModelInfo(modelId: string): { modelId: string; sharding: string | null; instanceType: string | null } {
+		let sharding: string | null = null;
+		let instanceType: string | null = null;
+
+		for (const [, instanceWrapper] of Object.entries(this.instances)) {
+			const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+			if (candidateModelId === modelId) {
+				const details = this.describeInstance(instanceWrapper);
+				sharding = details.sharding;
+				instanceType = details.instanceType;
+				break;
+			}
+		}
+
+		return { modelId, sharding, instanceType };
+	}
+
+	private applyConversationModelInfo(info: { modelId: string; sharding: string | null; instanceType: string | null }) {
+		if (!this.activeConversationId) return;
+		const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+		if (!conversation) return;
+
+		// Keep the first known modelId stable; only backfill if missing
+		if (!conversation.modelId) {
+			conversation.modelId = info.modelId;
+		}
+		conversation.sharding = info.sharding;
+		conversation.instanceType = info.instanceType;
+		this.saveConversationsToStorage();
+	}
+
+	private getModelTail(modelId: string): string {
+		const parts = modelId.split('/');
+		return (parts[parts.length - 1] || modelId).toLowerCase();
+	}
+
+	private isBetterModelId(currentId: string | null, candidateId: string | null): boolean {
+		if (!candidateId) return false;
+		if (!currentId) return true;
+		const currentTail = this.getModelTail(currentId);
+		const candidateTail = this.getModelTail(candidateId);
+		return candidateTail.length > currentTail.length && candidateTail.startsWith(currentTail);
+	}
+
+	private refreshConversationModelFromInstances() {
+		if (!this.activeConversationId) return;
+		const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+		if (!conversation) return;
+
+		// Prefer stored model; do not replace it once set. Only backfill when missing.
+		let modelId = conversation.modelId;
+
+		// If missing, try the selected model
+		if (!modelId && this.selectedChatModel) {
+			modelId = this.selectedChatModel;
+		}
+
+		// If still missing, fall back to first instance model
+		if (!modelId) {
+			const firstInstance = Object.values(this.instances)[0];
+			if (firstInstance) {
+				modelId = this.extractInstanceModelId(firstInstance);
+			}
+		}
+
+		if (!modelId) return;
+
+		// If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
+		let preferredModelId = modelId;
+		for (const [, instanceWrapper] of Object.entries(this.instances)) {
+			const candidate = this.extractInstanceModelId(instanceWrapper);
+			if (!candidate) continue;
+			if (candidate === preferredModelId) {
+				break;
+			}
+			if (this.isBetterModelId(preferredModelId, candidate)) {
+				preferredModelId = candidate;
+			}
+		}
+
+		if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
+			conversation.modelId = preferredModelId;
+		}
+
+		const info = this.buildConversationModelInfo(preferredModelId);
+		const hasNewInfo = Boolean(info.sharding || info.instanceType || !conversation.modelId);
+		if (hasNewInfo) {
+			this.applyConversationModelInfo(info);
+		}
+	}
+
+	getDebugMode(): boolean {
+		return this.debugMode;
+	}
+
+	/**
+	 * Update the active conversation with current messages
+	 */
+	private updateActiveConversation() {
+		if (!this.activeConversationId) return;
+		
+		const conversation = this.conversations.find(c => c.id === this.activeConversationId);
+		if (conversation) {
+			conversation.messages = [...this.messages];
+			conversation.updatedAt = Date.now();
+			
+			// Auto-generate name from first user message if still has default name
+			if (conversation.name.startsWith('Chat ')) {
+				const firstUserMsg = conversation.messages.find(m => m.role === 'user' && m.content.trim());
+				if (firstUserMsg) {
+					// Clean up the content - remove file context markers and whitespace
+					let content = firstUserMsg.content
+						.replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, '') // Remove file attachments
+						.trim();
+					
+					if (content) {
+						const preview = content.slice(0, 50);
+						conversation.name = preview.length < content.length ? preview + '...' : preview;
+					}
+				}
+			}
+			
+			this.saveConversationsToStorage();
+		}
+	}
+
+	private persistActiveConversation(throttleMs = 400) {
+		const now = Date.now();
+		if (now - this.lastConversationPersistTs < throttleMs) return;
+		this.lastConversationPersistTs = now;
+		this.updateActiveConversation();
+	}
+
+	/**
+	 * Toggle sidebar visibility
+	 */
+	toggleSidebar() {
+		this.isSidebarOpen = !this.isSidebarOpen;
+	}
+
+	setDebugMode(enabled: boolean) {
+		this.debugMode = enabled;
+		this.saveDebugModeToStorage();
+	}
+
+	toggleDebugMode() {
+		this.debugMode = !this.debugMode;
+		this.saveDebugModeToStorage();
+	}
+
+	startPolling() {
+		this.fetchState();
+		this.fetchInterval = setInterval(() => this.fetchState(), 1000);
+	}
+
+	stopPolling() {
+		if (this.fetchInterval) {
+			clearInterval(this.fetchInterval);
+			this.fetchInterval = null;
+		}
+		this.stopPreviewsPolling();
+	}
+
+	async fetchState() {
+		try {
+			const response = await fetch('/state');
+			if (!response.ok) {
+				throw new Error(`Failed to fetch state: ${response.status}`);
+			}
+			const data: RawStateResponse = await response.json();
+			
+			if (data.topology) {
+				this.topologyData = transformTopology(data.topology, data.nodeProfiles);
+			}
+			if (data.instances) {
+				this.instances = data.instances;
+				this.refreshConversationModelFromInstances();
+			}
+			if (data.runners) {
+				this.runners = data.runners;
+			}
+			if (data.downloads) {
+				this.downloads = data.downloads;
+			}
+			this.lastUpdate = Date.now();
+		} catch (error) {
+			console.error('Error fetching state:', error);
+		}
+	}
+
+	async fetchPlacementPreviews(modelId: string, showLoading = true) {
+		if (!modelId) return;
+		
+		if (showLoading) {
+			this.isLoadingPreviews = true;
+		}
+		this.selectedPreviewModelId = modelId;
+		
+		try {
+			const response = await fetch(`/instance/previews?model_id=${encodeURIComponent(modelId)}`);
+			if (!response.ok) {
+				throw new Error(`Failed to fetch placement previews: ${response.status}`);
+			}
+			const data: PlacementPreviewResponse = await response.json();
+			this.placementPreviews = data.previews;
+		} catch (error) {
+			console.error('Error fetching placement previews:', error);
+			this.placementPreviews = [];
+		} finally {
+			if (showLoading) {
+				this.isLoadingPreviews = false;
+			}
+		}
+	}
+	
+	startPreviewsPolling(modelId: string) {
+		// Stop any existing preview polling
+		this.stopPreviewsPolling();
+		
+		// Fetch immediately
+		this.fetchPlacementPreviews(modelId);
+		
+		// Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
+		this.previewsInterval = setInterval(() => {
+			if (this.selectedPreviewModelId) {
+				this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+			}
+		}, 15000);
+	}
+	
+	stopPreviewsPolling() {
+		if (this.previewsInterval) {
+			clearInterval(this.previewsInterval);
+			this.previewsInterval = null;
+		}
+	}
+	
+	selectPreviewModel(modelId: string | null) {
+		if (modelId) {
+			this.startPreviewsPolling(modelId);
+		} else {
+			this.stopPreviewsPolling();
+			this.selectedPreviewModelId = null;
+			this.placementPreviews = [];
+		}
+	}
+
+	/**
+	 * Starts a chat conversation - triggers the topology minimization animation
+	 * Creates a new conversation if none is active
+	 */
+	startChat() {
+		if (!this.activeConversationId) {
+			this.createConversation();
+		} else {
+			this.hasStartedChat = true;
+			this.isSidebarOpen = true; // Auto-open sidebar when chatting
+			// Small delay before minimizing for a nice visual effect
+			setTimeout(() => {
+				this.isTopologyMinimized = true;
+			}, 100);
+		}
+	}
+
+	/**
+	 * Add a message to the conversation
+	 */
+	addMessage(role: 'user' | 'assistant', content: string) {
+		const message: Message = {
+			id: generateUUID(),
+			role,
+			content,
+			timestamp: Date.now()
+		};
+		this.messages.push(message);
+		return message;
+	}
+
+	/**
+	 * Delete a message and all subsequent messages
+	 */
+	deleteMessage(messageId: string) {
+		const messageIndex = this.messages.findIndex(m => m.id === messageId);
+		if (messageIndex === -1) return;
+		
+		// Remove this message and all subsequent messages
+		this.messages = this.messages.slice(0, messageIndex);
+		this.updateActiveConversation();
+	}
+
+	/**
+	 * Edit a user message content (does not regenerate response)
+	 */
+	editMessage(messageId: string, newContent: string) {
+		const message = this.messages.find(m => m.id === messageId);
+		if (!message) return;
+		
+		message.content = newContent;
+		message.timestamp = Date.now();
+		this.updateActiveConversation();
+	}
+
+	/**
+	 * Edit a user message and regenerate the response
+	 */
+	async editAndRegenerate(messageId: string, newContent: string): Promise<void> {
+		const messageIndex = this.messages.findIndex(m => m.id === messageId);
+		if (messageIndex === -1) return;
+		
+		const message = this.messages[messageIndex];
+		if (message.role !== 'user') return;
+		
+		// Update the message content
+		message.content = newContent;
+		message.timestamp = Date.now();
+		
+		// Remove all messages after this one (including the assistant response)
+		this.messages = this.messages.slice(0, messageIndex + 1);
+		
+		// Regenerate the response
+		await this.regenerateLastResponse();
+	}
+
+	/**
+	 * Regenerate the last assistant response
+	 */
+	async regenerateLastResponse(): Promise<void> {
+		if (this.isLoading) return;
+		
+		// Find the last user message
+		let lastUserIndex = -1;
+		for (let i = this.messages.length - 1; i >= 0; i--) {
+			if (this.messages[i].role === 'user') {
+				lastUserIndex = i;
+				break;
+			}
+		}
+		
+		if (lastUserIndex === -1) return;
+		
+		const lastUserMessage = this.messages[lastUserIndex];
+		
+		// Remove any messages after the user message
+		this.messages = this.messages.slice(0, lastUserIndex + 1);
+		
+		// Resend the message to get a new response
+		this.isLoading = true;
+		this.currentResponse = '';
+		
+		// Create placeholder for assistant message
+		const assistantMessage = this.addMessage('assistant', '');
+		
+		try {
+			const systemPrompt = {
+				role: 'system' as const,
+				content: 'You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.'
+			};
+			
+			const apiMessages = [
+				systemPrompt,
+				...this.messages.slice(0, -1).map((m) => {
+					return { role: m.role, content: m.content };
+				})
+			];
+			
+			// Determine which model to use
+			let modelToUse = this.selectedChatModel;
+			if (!modelToUse) {
+				const firstInstanceKey = Object.keys(this.instances)[0];
+				if (firstInstanceKey) {
+					const instance = this.instances[firstInstanceKey] as Record<string, unknown> | undefined;
+					if (instance) {
+						const keys = Object.keys(instance);
+						if (keys.length === 1) {
+							const inst = instance[keys[0]] as { shardAssignments?: { modelId?: string } } | undefined;
+							modelToUse = inst?.shardAssignments?.modelId || '';
+						}
+					}
+				}
+			}
+			
+			if (!modelToUse) {
+				assistantMessage.content = 'Error: No model available. Please launch an instance first.';
+				this.isLoading = false;
+				this.updateActiveConversation();
+				return;
+			}
+			
+			const response = await fetch('/v1/chat/completions', {
+				method: 'POST',
+				headers: { 'Content-Type': 'application/json' },
+				body: JSON.stringify({
+					model: modelToUse,
+					messages: apiMessages,
+					stream: true
+				})
+			});
+			
+			if (!response.ok) {
+				const errorText = await response.text();
+				assistantMessage.content = `Error: ${response.status} - ${errorText}`;
+				this.isLoading = false;
+				this.updateActiveConversation();
+				return;
+			}
+			
+			const reader = response.body?.getReader();
+			if (!reader) {
+				assistantMessage.content = 'Error: No response stream available';
+				this.isLoading = false;
+				this.updateActiveConversation();
+				return;
+			}
+			
+			const decoder = new TextDecoder();
+			let fullContent = '';
+			let partialLine = '';
+			
+			while (true) {
+				const { done, value } = await reader.read();
+				if (done) break;
+				
+				const chunk = decoder.decode(value, { stream: true });
+				const lines = (partialLine + chunk).split('\n');
+				partialLine = lines.pop() || '';
+				
+				for (const line of lines) {
+					const trimmed = line.trim();
+					if (!trimmed || trimmed === 'data: [DONE]') continue;
+					
+					if (trimmed.startsWith('data: ')) {
+						try {
+							const json = JSON.parse(trimmed.slice(6));
+							const delta = json.choices?.[0]?.delta?.content;
+							if (delta) {
+								fullContent += delta;
+								const { displayContent } = this.stripThinkingTags(fullContent);
+								this.currentResponse = displayContent;
+								assistantMessage.content = displayContent;
+							}
+						} catch {
+							// Skip malformed JSON
+						}
+					}
+				}
+			}
+			
+			const { displayContent } = this.stripThinkingTags(fullContent);
+			assistantMessage.content = displayContent;
+			this.currentResponse = '';
+			this.updateActiveConversation();
+			
+		} catch (error) {
+			assistantMessage.content = `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
+			this.updateActiveConversation();
+		} finally {
+			this.isLoading = false;
+		}
+	}
+
+	/**
+	 * Selected model for chat (can be set by the UI)
+	 */
+	selectedChatModel = $state('');
+
+	/**
+	 * Set the model to use for chat
+	 */
+	setSelectedModel(modelId: string) {
+		this.selectedChatModel = modelId;
+		// Clear stats when model changes
+		this.ttftMs = null;
+		this.tps = null;
+	}
+
+	/**
+	 * Strip thinking tags from content for display.
+	 * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
+	 */
+	private stripThinkingTags(content: string): { displayContent: string; thinkingContent: string } {
+		const extracted: string[] = [];
+		let displayContent = content;
+
+		// Extract complete <think>...</think> blocks
+		const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
+		let match: RegExpExecArray | null;
+		while ((match = completeBlockRegex.exec(content)) !== null) {
+			const inner = match[1]?.trim();
+			if (inner) extracted.push(inner);
+		}
+		displayContent = displayContent.replace(completeBlockRegex, '');
+
+		// Handle in-progress thinking block (has <think> but no closing </think> yet)
+		const openTagIndex = displayContent.lastIndexOf('<think>');
+		if (openTagIndex !== -1) {
+			const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
+			if (inProgressThinking) {
+				extracted.push(inProgressThinking);
+			}
+			displayContent = displayContent.slice(0, openTagIndex);
+		}
+
+		return { displayContent: displayContent.trim(), thinkingContent: extracted.join('\n\n') };
+	}
+
+	/**
+	 * Send a message to the LLM and stream the response
+	 */
+	async sendMessage(content: string, files?: { id: string; name: string; type: string; textContent?: string; preview?: string }[]): Promise<void> {
+		if ((!content.trim() && (!files || files.length === 0)) || this.isLoading) return;
+		
+		if (!this.hasStartedChat) {
+			this.startChat();
+		}
+
+		this.isLoading = true;
+		this.currentResponse = '';
+		this.ttftMs = null;
+		this.tps = null;
+		this.totalTokens = 0;
+		
+		// Build attachments from files
+		const attachments: MessageAttachment[] = [];
+		let fileContext = '';
+		
+		if (files && files.length > 0) {
+			for (const file of files) {
+				const isImage = file.type.startsWith('image/');
+				
+				if (isImage && file.preview) {
+					attachments.push({
+						type: 'image',
+						name: file.name,
+						preview: file.preview,
+						mimeType: file.type
+					});
+				} else if (file.textContent) {
+					attachments.push({
+						type: 'text',
+						name: file.name,
+						content: file.textContent,
+						mimeType: file.type
+					});
+					// Add text file content to the message context
+					fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
+				} else {
+					attachments.push({
+						type: 'file',
+						name: file.name,
+						mimeType: file.type
+					});
+				}
+			}
+		}
+		
+		// Combine content with file context
+		const fullContent = content + fileContext;
+		
+		// Add user message with attachments
+		const userMessage: Message = {
+			id: generateUUID(),
+			role: 'user',
+			content: content, // Store original content for display
+			timestamp: Date.now(),
+			attachments: attachments.length > 0 ? attachments : undefined
+		};
+		this.messages.push(userMessage);
+		
+		// Create placeholder for assistant message
+		const assistantMessage = this.addMessage('assistant', '');
+		this.updateActiveConversation();
+		
+		try {
+			// Build the messages array for the API with system prompt
+			const systemPrompt = {
+				role: 'system' as const,
+				content: 'You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.'
+			};
+			
+			// Build API messages - include file content for text files
+			const apiMessages = [
+				systemPrompt,
+				...this.messages.slice(0, -1).map((m) => {
+					// Build content including any text file attachments
+					let msgContent = m.content;
+					
+					// Add text attachments as context
+					if (m.attachments) {
+						for (const attachment of m.attachments) {
+							if (attachment.type === 'text' && attachment.content) {
+								msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
+							}
+						}
+					}
+					
+					return {
+						role: m.role,
+						content: msgContent
+					};
+				})
+			];
+
+			// Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
+			let modelToUse = this.selectedChatModel;
+			if (!modelToUse) {
+				// Try to get model from first running instance
+				for (const [, instanceWrapper] of Object.entries(this.instances)) {
+					if (instanceWrapper && typeof instanceWrapper === 'object') {
+						const keys = Object.keys(instanceWrapper as Record<string, unknown>);
+						if (keys.length === 1) {
+							const instance = (instanceWrapper as Record<string, unknown>)[keys[0]] as { shardAssignments?: { modelId?: string } };
+							if (instance?.shardAssignments?.modelId) {
+								modelToUse = instance.shardAssignments.modelId;
+								break;
+							}
+						}
+					}
+				}
+			}
+
+			if (!modelToUse) {
+				throw new Error('No model selected and no running instances available. Please launch an instance first.');
+			}
+
+			const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
+			this.applyConversationModelInfo(conversationModelInfo);
+
+			// Start timing for TTFT measurement
+			const requestStartTime = performance.now();
+			let firstTokenTime: number | null = null;
+			let tokenCount = 0;
+			
+			const response = await fetch('/v1/chat/completions', {
+				method: 'POST',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify({
+					model: modelToUse,
+					messages: apiMessages,
+					temperature: 0.7,
+					stream: true
+				})
+			});
+
+			if (!response.ok) {
+				const errorText = await response.text();
+				throw new Error(`API error: ${response.status} - ${errorText}`);
+			}
+
+			const reader = response.body?.getReader();
+			if (!reader) {
+				throw new Error('No response body');
+			}
+
+			const decoder = new TextDecoder();
+			let fullContent = '';
+			let buffer = '';
+
+			while (true) {
+				const { done, value } = await reader.read();
+				if (done) break;
+
+				buffer += decoder.decode(value, { stream: true });
+				
+				// Process complete lines
+				const lines = buffer.split('\n');
+				buffer = lines.pop() || ''; // Keep incomplete line in buffer
+
+				for (const line of lines) {
+					const trimmed = line.trim();
+					if (!trimmed) continue;
+					
+					if (trimmed.startsWith('data: ')) {
+						const data = trimmed.slice(6);
+						if (data === '[DONE]') continue;
+
+						try {
+							const parsed = JSON.parse(data);
+							const tokenContent = parsed.choices?.[0]?.delta?.content;
+							if (tokenContent) {
+								// Track first token for TTFT
+								if (firstTokenTime === null) {
+									firstTokenTime = performance.now();
+									this.ttftMs = firstTokenTime - requestStartTime;
+								}
+								
+								// Count tokens (each SSE chunk is typically one token)
+								tokenCount += 1;
+								this.totalTokens = tokenCount;
+								
+								// Update real-time TPS during streaming
+								if (firstTokenTime !== null && tokenCount > 1) {
+									const elapsed = performance.now() - firstTokenTime;
+									this.tps = (tokenCount / elapsed) * 1000;
+								}
+								
+								fullContent += tokenContent;
+								
+								// Strip thinking tags for display and extract thinking content
+								const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent);
+								this.currentResponse = displayContent;
+								
+								// Update the assistant message in place
+								const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+								if (idx !== -1) {
+									this.messages[idx].content = displayContent;
+									this.messages[idx].thinking = thinkingContent || undefined;
+								}
+								this.persistActiveConversation();
+							}
+						} catch {
+							// Skip invalid JSON lines
+						}
+					}
+				}
+			}
+			
+			// Process any remaining buffer
+			if (buffer.trim()) {
+				const trimmed = buffer.trim();
+				if (trimmed.startsWith('data: ') && trimmed.slice(6) !== '[DONE]') {
+					try {
+						const parsed = JSON.parse(trimmed.slice(6));
+						const tokenContent = parsed.choices?.[0]?.delta?.content;
+						if (tokenContent) {
+							fullContent += tokenContent;
+							this.persistActiveConversation();
+						}
+					} catch {
+						// Skip
+					}
+				}
+			}
+			
+			// Calculate final TPS
+			if (firstTokenTime !== null && tokenCount > 1) {
+				const totalGenerationTime = performance.now() - firstTokenTime;
+				this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
+			}
+			
+			// Final cleanup of the message
+			const { displayContent, thinkingContent } = this.stripThinkingTags(fullContent);
+			const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+			if (idx !== -1) {
+				this.messages[idx].content = displayContent;
+				this.messages[idx].thinking = thinkingContent || undefined;
+				// Store performance metrics on the message
+				if (this.ttftMs !== null) {
+					this.messages[idx].ttftMs = this.ttftMs;
+				}
+				if (this.tps !== null) {
+					this.messages[idx].tps = this.tps;
+				}
+			}
+			this.persistActiveConversation();
+			
+		} catch (error) {
+			console.error('Error sending message:', error);
+			// Update the assistant message with error
+			const idx = this.messages.findIndex(m => m.id === assistantMessage.id);
+			if (idx !== -1) {
+				this.messages[idx].content = `Error: ${error instanceof Error ? error.message : 'Failed to get response'}`;
+			}
+			this.persistActiveConversation();
+		} finally {
+			this.isLoading = false;
+			this.currentResponse = '';
+			this.updateActiveConversation();
+		}
+	}
+
+	/**
+	 * Clear current chat and go back to welcome state
+	 */
+	clearChat() {
+		this.activeConversationId = null;
+		this.messages = [];
+		this.hasStartedChat = false;
+		this.isTopologyMinimized = false;
+		this.currentResponse = '';
+		// Clear performance stats
+		this.ttftMs = null;
+		this.tps = null;
+	}
+
+	/**
+	 * Get the active conversation
+	 */
+	getActiveConversation(): Conversation | null {
+		if (!this.activeConversationId) return null;
+		return this.conversations.find(c => c.id === this.activeConversationId) || null;
+	}
+}
+
+export const appStore = new AppStore();
+
+// Reactive exports
+export const hasStartedChat = () => appStore.hasStartedChat;
+export const messages = () => appStore.messages;
+export const currentResponse = () => appStore.currentResponse;
+export const isLoading = () => appStore.isLoading;
+export const ttftMs = () => appStore.ttftMs;
+export const tps = () => appStore.tps;
+export const totalTokens = () => appStore.totalTokens;
+export const topologyData = () => appStore.topologyData;
+export const instances = () => appStore.instances;
+export const runners = () => appStore.runners;
+export const downloads = () => appStore.downloads;
+export const placementPreviews = () => appStore.placementPreviews;
+export const selectedPreviewModelId = () => appStore.selectedPreviewModelId;
+export const isLoadingPreviews = () => appStore.isLoadingPreviews;
+export const lastUpdate = () => appStore.lastUpdate;
+export const isTopologyMinimized = () => appStore.isTopologyMinimized;
+export const selectedChatModel = () => appStore.selectedChatModel;
+export const debugMode = () => appStore.getDebugMode();
+
+// Actions
+export const startChat = () => appStore.startChat();
+export const sendMessage = (content: string, files?: { id: string; name: string; type: string; textContent?: string; preview?: string }[]) => appStore.sendMessage(content, files);
+export const clearChat = () => appStore.clearChat();
+export const setSelectedChatModel = (modelId: string) => appStore.setSelectedModel(modelId);
+export const selectPreviewModel = (modelId: string | null) => appStore.selectPreviewModel(modelId);
+export const deleteMessage = (messageId: string) => appStore.deleteMessage(messageId);
+export const editMessage = (messageId: string, newContent: string) => appStore.editMessage(messageId, newContent);
+export const editAndRegenerate = (messageId: string, newContent: string) => appStore.editAndRegenerate(messageId, newContent);
+export const regenerateLastResponse = () => appStore.regenerateLastResponse();
+
+// Conversation actions
+export const conversations = () => appStore.conversations;
+export const activeConversationId = () => appStore.activeConversationId;
+export const createConversation = (name?: string) => appStore.createConversation(name);
+export const loadConversation = (id: string) => appStore.loadConversation(id);
+export const deleteConversation = (id: string) => appStore.deleteConversation(id);
+export const deleteAllConversations = () => appStore.deleteAllConversations();
+export const renameConversation = (id: string, name: string) => appStore.renameConversation(id, name);
+export const getActiveConversation = () => appStore.getActiveConversation();
+
+// Sidebar actions
+export const isSidebarOpen = () => appStore.isSidebarOpen;
+export const toggleSidebar = () => appStore.toggleSidebar();
+export const toggleDebugMode = () => appStore.toggleDebugMode();
+export const setDebugMode = (enabled: boolean) => appStore.setDebugMode(enabled);
+export const refreshState = () => appStore.fetchState();
+
diff --git a/dashboard/src/lib/types/files.ts b/dashboard/src/lib/types/files.ts
new file mode 100644
index 00000000..b92e269e
--- /dev/null
+++ b/dashboard/src/lib/types/files.ts
@@ -0,0 +1,169 @@
+/**
+ * File attachment types for the chat interface
+ */
+
+export interface ChatUploadedFile {
+	id: string;
+	name: string;
+	size: number;
+	type: string;
+	file: File;
+	preview?: string;
+	textContent?: string;
+}
+
+export interface ChatAttachment {
+	type: 'image' | 'text' | 'pdf' | 'audio';
+	name: string;
+	content?: string;
+	base64Url?: string;
+	mimeType?: string;
+}
+
+export type FileCategory = 'image' | 'text' | 'pdf' | 'audio' | 'unknown';
+
+export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
+export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
+
+export const TEXT_EXTENSIONS = [
+	'.txt', '.md', '.json', '.xml', '.yaml', '.yml', '.csv', '.log',
+	'.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.cpp', '.c', '.h',
+	'.css', '.html', '.htm', '.sql', '.sh', '.bat', '.rs', '.go',
+	'.rb', '.php', '.swift', '.kt', '.scala', '.r', '.dart', '.vue', '.svelte'
+];
+export const TEXT_MIME_TYPES = [
+	'text/plain', 'text/markdown', 'text/csv', 'text/html', 'text/css',
+	'application/json', 'application/xml', 'text/xml', 'application/javascript',
+	'text/javascript', 'application/typescript'
+];
+
+export const PDF_EXTENSIONS = ['.pdf'];
+export const PDF_MIME_TYPES = ['application/pdf'];
+
+export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a'];
+export const AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4'];
+
+/**
+ * Get file category based on MIME type and extension
+ */
+export function getFileCategory(mimeType: string, fileName: string): FileCategory {
+	const extension = fileName.toLowerCase().slice(fileName.lastIndexOf('.'));
+	
+	if (IMAGE_MIME_TYPES.includes(mimeType) || IMAGE_EXTENSIONS.includes(extension)) {
+		return 'image';
+	}
+	if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
+		return 'pdf';
+	}
+	if (AUDIO_MIME_TYPES.includes(mimeType) || AUDIO_EXTENSIONS.includes(extension)) {
+		return 'audio';
+	}
+	if (TEXT_MIME_TYPES.includes(mimeType) || TEXT_EXTENSIONS.includes(extension) || mimeType.startsWith('text/')) {
+		return 'text';
+	}
+	return 'unknown';
+}
+
+/**
+ * Get accept string for file input based on categories
+ */
+export function getAcceptString(categories: FileCategory[]): string {
+	const accepts: string[] = [];
+	
+	for (const category of categories) {
+		switch (category) {
+			case 'image':
+				accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
+				break;
+			case 'text':
+				accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
+				break;
+			case 'pdf':
+				accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
+				break;
+			case 'audio':
+				accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
+				break;
+		}
+	}
+	
+	return accepts.join(',');
+}
+
+/**
+ * Format file size for display
+ */
+export function formatFileSize(bytes: number): string {
+	if (bytes === 0) return '0 B';
+	const k = 1024;
+	const sizes = ['B', 'KB', 'MB', 'GB'];
+	const i = Math.floor(Math.log(bytes) / Math.log(k));
+	return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+}
+
+/**
+ * Read file as data URL (base64)
+ */
+export function readFileAsDataURL(file: File): Promise<string> {
+	return new Promise((resolve, reject) => {
+		const reader = new FileReader();
+		reader.onload = () => resolve(reader.result as string);
+		reader.onerror = () => reject(reader.error);
+		reader.readAsDataURL(file);
+	});
+}
+
+/**
+ * Read file as text
+ */
+export function readFileAsText(file: File): Promise<string> {
+	return new Promise((resolve, reject) => {
+		const reader = new FileReader();
+		reader.onload = () => resolve(reader.result as string);
+		reader.onerror = () => reject(reader.error);
+		reader.readAsText(file);
+	});
+}
+
+/**
+ * Process uploaded files into ChatUploadedFile format
+ */
+export async function processUploadedFiles(files: File[]): Promise<ChatUploadedFile[]> {
+	const results: ChatUploadedFile[] = [];
+	
+	for (const file of files) {
+		const id = Date.now().toString() + Math.random().toString(36).substring(2, 9);
+		const category = getFileCategory(file.type, file.name);
+		
+		const base: ChatUploadedFile = {
+			id,
+			name: file.name,
+			size: file.size,
+			type: file.type,
+			file
+		};
+		
+		try {
+			if (category === 'image') {
+				const preview = await readFileAsDataURL(file);
+				results.push({ ...base, preview });
+			} else if (category === 'text' || category === 'unknown') {
+				const textContent = await readFileAsText(file);
+				results.push({ ...base, textContent });
+			} else if (category === 'pdf') {
+				results.push(base);
+			} else if (category === 'audio') {
+				const preview = await readFileAsDataURL(file);
+				results.push({ ...base, preview });
+			} else {
+				results.push(base);
+			}
+		} catch (error) {
+			console.error('Error processing file:', file.name, error);
+			results.push(base);
+		}
+	}
+	
+	return results;
+}
+
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
new file mode 100644
index 00000000..7e75b676
--- /dev/null
+++ b/dashboard/src/routes/+layout.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+	import '../app.css';
+	
+	let { children } = $props();
+</script>
+
+<svelte:head>
+	<title>EXO</title>
+	<meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
+</svelte:head>
+
+<div class="min-h-screen bg-background text-foreground">
+	{@render children?.()}
+</div>
+
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
new file mode 100644
index 00000000..082d1138
--- /dev/null
+++ b/dashboard/src/routes/+page.svelte
@@ -0,0 +1,1840 @@
+<script lang="ts">
+	import { TopologyGraph, ChatForm, ChatMessages, ChatSidebar, ModelCard } from '$lib/components';
+	import { 
+		hasStartedChat, 
+		isTopologyMinimized, 
+		topologyData, 
+		lastUpdate,
+		clearChat,
+		instances,
+		runners,
+		downloads,
+		placementPreviews,
+		selectedPreviewModelId,
+		isLoadingPreviews,
+		selectPreviewModel,
+		createConversation,
+		setSelectedChatModel,
+		selectedChatModel,
+	debugMode,
+	toggleDebugMode,
+		type DownloadProgress,
+		type PlacementPreview
+	} from '$lib/stores/app.svelte';
+	import HeaderNav from '$lib/components/HeaderNav.svelte';
+	import { fade, fly } from 'svelte/transition';
+	import { cubicInOut } from 'svelte/easing';
+	import { onMount } from 'svelte';
+
+	const chatStarted = $derived(hasStartedChat());
+	const minimized = $derived(isTopologyMinimized());
+	const data = $derived(topologyData());
+	const update = $derived(lastUpdate());
+	const instanceData = $derived(instances());
+	const runnersData = $derived(runners());
+	const downloadsData = $derived(downloads());
+	const previewsData = $derived(placementPreviews());
+	const selectedModelId = $derived(selectedPreviewModelId());
+	const loadingPreviews = $derived(isLoadingPreviews());
+const debugEnabled = $derived(debugMode());
+
+	let mounted = $state(false);
+
+	// Instance launch state
+	let models = $state<Array<{id: string, name?: string, storage_size_megabytes?: number}>>([]);
+	let selectedSharding = $state<'Pipeline' | 'Tensor'>('Pipeline');
+	type InstanceMeta = 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
+	
+	let selectedInstanceType = $state<InstanceMeta>('MlxRing');
+	let selectedMinNodes = $state<number>(1);
+	let minNodesInitialized = $state(false);
+	let launchingModelId = $state<string | null>(null);
+let instanceDownloadExpandedNodes = $state<Set<string>>(new Set());
+	
+	// Custom dropdown state
+	let isModelDropdownOpen = $state(false);
+	let modelDropdownSearch = $state('');
+	
+	// Slider dragging state
+	let isDraggingSlider = $state(false);
+	let sliderTrackElement: HTMLDivElement | null = $state(null);
+	
+	// Instances container ref for scrolling
+	let instancesContainerRef: HTMLDivElement | null = $state(null);
+// Chat scroll container ref for precise scroll behavior
+let chatScrollRef: HTMLDivElement | null = $state(null);
+	
+	// Instance hover state for highlighting nodes in topology
+	let hoveredInstanceId = $state<string | null>(null);
+	
+	// Preview card hover state for highlighting nodes in topology
+	let hoveredPreviewNodes = $state<Set<string>>(new Set());
+	
+	// Helper to unwrap tagged instance for hover highlighting
+	function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
+		if (!instanceWrapped || typeof instanceWrapped !== 'object') return new Set();
+		const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+		if (keys.length !== 1) return new Set();
+		const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
+		if (!instance || typeof instance !== 'object') return new Set();
+		const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string> } };
+		if (!inst.shardAssignments?.nodeToRunner) return new Set();
+		return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
+	}
+
+function toggleInstanceDownloadDetails(nodeId: string): void {
+	const next = new Set(instanceDownloadExpandedNodes);
+	if (next.has(nodeId)) {
+		next.delete(nodeId);
+	} else {
+		next.add(nodeId);
+	}
+	instanceDownloadExpandedNodes = next;
+}
+	
+	// Compute highlighted nodes from hovered instance or hovered preview
+	const highlightedNodes = $derived(() => {
+		// First check instance hover
+		if (hoveredInstanceId) {
+			const instanceWrapped = instanceData[hoveredInstanceId];
+			return unwrapInstanceNodes(instanceWrapped);
+		}
+		// Then check preview hover
+		if (hoveredPreviewNodes.size > 0) {
+			return hoveredPreviewNodes;
+		}
+		return new Set<string>();
+	});
+	
+	// Helper to estimate memory from model ID (mirrors ModelCard logic)
+	// Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
+	function estimateMemoryGB(modelId: string, modelName?: string): number {
+		// Check both ID and name for quantization info
+		const combined = `${modelId} ${modelName || ''}`.toLowerCase();
+		
+		// Detect quantization level - affects memory by roughly 2x between levels
+		const is4bit = combined.includes('4bit') || combined.includes('4-bit') || combined.includes(':4bit');
+		const is8bit = combined.includes('8bit') || combined.includes('8-bit') || combined.includes(':8bit');
+		// 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
+		const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
+		const id = modelId.toLowerCase();
+		
+		// Known large models that don't follow the standard naming pattern
+		// DeepSeek V3 has 685B parameters
+		if (id.includes('deepseek-v3')) {
+			return Math.round(685 * quantMultiplier);
+		}
+		// DeepSeek V2 has 236B parameters  
+		if (id.includes('deepseek-v2')) {
+			return Math.round(236 * quantMultiplier);
+		}
+		// Llama 4 Scout/Maverick are large models
+		if (id.includes('llama-4')) {
+			return Math.round(400 * quantMultiplier);
+		}
+		
+		// Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
+		const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
+		if (paramMatch) {
+			const params = parseFloat(paramMatch[1]);
+			return Math.max(4, Math.round(params * quantMultiplier));
+		}
+		
+		// Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
+		if (id.includes('405b') || id.includes('400b')) return Math.round(405 * quantMultiplier);
+		if (id.includes('180b')) return Math.round(180 * quantMultiplier);
+		if (id.includes('141b') || id.includes('140b')) return Math.round(140 * quantMultiplier);
+		if (id.includes('123b') || id.includes('120b')) return Math.round(123 * quantMultiplier);
+		if (id.includes('72b') || id.includes('70b')) return Math.round(70 * quantMultiplier);
+		if (id.includes('67b') || id.includes('65b')) return Math.round(65 * quantMultiplier);
+		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return Math.round(32 * quantMultiplier);
+		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return Math.round(24 * quantMultiplier);
+		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return Math.round(14 * quantMultiplier);
+		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return Math.round(8 * quantMultiplier);
+		if (id.includes('3b') || id.includes('3.8b')) return Math.round(4 * quantMultiplier);
+		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return Math.round(2 * quantMultiplier);
+		return 16; // Default fallback
+	}
+	
+	// Helper to estimate performance from model ID
+	function estimatePerformance(modelId: string): { ttft: number; tps: number } {
+		const id = modelId.toLowerCase();
+		if (id.includes('405b') || id.includes('400b')) return { ttft: 8000, tps: 3 };
+		if (id.includes('180b')) return { ttft: 4000, tps: 5 };
+		if (id.includes('141b') || id.includes('140b')) return { ttft: 3500, tps: 6 };
+		if (id.includes('123b') || id.includes('120b')) return { ttft: 3000, tps: 7 };
+		if (id.includes('72b') || id.includes('70b')) return { ttft: 1800, tps: 12 };
+		if (id.includes('67b') || id.includes('65b')) return { ttft: 1600, tps: 14 };
+		if (id.includes('35b') || id.includes('34b') || id.includes('32b') || id.includes('30b')) return { ttft: 900, tps: 22 };
+		if (id.includes('27b') || id.includes('26b') || id.includes('22b')) return { ttft: 700, tps: 28 };
+		if (id.includes('14b') || id.includes('13b') || id.includes('15b')) return { ttft: 400, tps: 45 };
+		if (id.includes('8b') || id.includes('9b') || id.includes('7b')) return { ttft: 200, tps: 65 };
+		if (id.includes('4b') || id.includes('3b') || id.includes('3.8b')) return { ttft: 100, tps: 95 };
+		if (id.includes('2b') || id.includes('1b') || id.includes('1.5b') || id.includes('0.5b')) return { ttft: 50, tps: 150 };
+		return { ttft: 300, tps: 50 };
+	}
+	
+	const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
+		selectedInstanceType === 'MlxRing'
+			? runtime === 'MlxRing'
+			: runtime === 'MlxIbv' || runtime === 'MlxJaccl';
+
+	// Helper to check if a model can be launched (has valid placement with >= minNodes)
+	function canModelFit(modelId: string): boolean {
+
+		// Find previews matching the model, sharding, and instance type
+		const matchingPreviews = previewsData.filter(
+			(p: PlacementPreview) => 
+				p.model_id === modelId && 
+				p.sharding === selectedSharding && 
+				matchesSelectedRuntime(p.instance_meta) &&
+				p.error === null &&
+				p.memory_delta_by_node !== null
+		);
+		
+		// Check if any preview has node count >= selectedMinNodes
+		return matchingPreviews.some((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes);
+	}
+	
+	// Helper to get model size in GB (from megabytes)
+	function getModelSizeGB(model: {id: string, name?: string, storage_size_megabytes?: number}): number {
+		if (model.storage_size_megabytes) {
+			return model.storage_size_megabytes / 1024;
+		}
+		return estimateMemoryGB(model.id, model.name);
+	}
+	
+	// Calculate available memory in the cluster (in GB)
+	const availableMemoryGB = $derived(() => {
+		if (!data) return 0;
+		return Object.values(data.nodes).reduce((acc, n) => {
+			const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+			const used = n.macmon_info?.memory?.ram_usage ?? 0;
+			return acc + (total - used);
+		}, 0) / (1024 * 1024 * 1024);
+	});
+	
+	// Check if a model has enough memory to run
+	function hasEnoughMemory(model: {id: string, name?: string, storage_size_megabytes?: number}): boolean {
+		const modelSizeGB = getModelSizeGB(model);
+		return modelSizeGB <= availableMemoryGB();
+	}
+	
+	// Sorted models for dropdown - biggest first, unrunnable at the end
+	const sortedModels = $derived(() => {
+		return [...models].sort((a, b) => {
+			// First: models that have enough memory come before those that don't
+			const aCanFit = hasEnoughMemory(a);
+			const bCanFit = hasEnoughMemory(b);
+			if (aCanFit && !bCanFit) return -1;
+			if (!aCanFit && bCanFit) return 1;
+			
+			// Then: sort by size (biggest first)
+			const aSize = getModelSizeGB(a);
+			const bSize = getModelSizeGB(b);
+			return bSize - aSize;
+		});
+	});
+	
+	// Compute model tags (FASTEST, BIGGEST)
+	const modelTags = $derived(() => {
+		const tags: Record<string, string[]> = {};
+		if (models.length === 0) return tags;
+		
+		// Find the fastest model (highest TPS)
+		let fastestId = '';
+		let highestTps = 0;
+		
+		// Find the biggest model (most memory)
+		let biggestId = '';
+		let highestMemory = 0;
+		
+		for (const model of models) {
+			const perf = estimatePerformance(model.id);
+			const mem = getModelSizeGB(model);
+			
+			if (perf.tps > highestTps) {
+				highestTps = perf.tps;
+				fastestId = model.id;
+			}
+			
+			if (mem > highestMemory) {
+				highestMemory = mem;
+				biggestId = model.id;
+			}
+		}
+		
+		if (fastestId) {
+			tags[fastestId] = tags[fastestId] || [];
+			tags[fastestId].push('FASTEST');
+		}
+		
+		if (biggestId && biggestId !== fastestId) {
+			tags[biggestId] = tags[biggestId] || [];
+			tags[biggestId].push('BIGGEST');
+		} else if (biggestId === fastestId && biggestId) {
+			// Same model is both - unlikely but handle it
+			tags[biggestId].push('BIGGEST');
+		}
+		
+		return tags;
+	});
+
+	onMount(() => {
+		mounted = true;
+		fetchModels();
+	});
+
+	async function fetchModels() {
+		try {
+			const response = await fetch('/models');
+			if (response.ok) {
+				const data = await response.json();
+				// API returns { data: [{ id, name }] } format
+				models = data.data || [];
+			}
+		} catch (error) {
+			console.error('Failed to fetch models:', error);
+		}
+	}
+
+	async function launchInstance(modelId: string, specificPreview?: PlacementPreview | null) {
+		if (!modelId || launchingModelId) return;
+		
+		launchingModelId = modelId;
+		
+		try {
+			// Use the specific preview if provided, otherwise fall back to filtered preview
+			const preview = specificPreview ?? filteredPreview();
+			
+			let instanceData: unknown;
+			
+			if (preview?.instance) {
+				// Use the instance from the preview
+				instanceData = preview.instance;
+			} else {
+				// Fallback: GET placement from API
+				const placementResponse = await fetch(
+					`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`
+				);
+				
+				if (!placementResponse.ok) {
+					const errorText = await placementResponse.text();
+					console.error('Failed to get placement:', errorText);
+					return;
+				}
+				
+				instanceData = await placementResponse.json();
+			}
+			
+			// POST the instance to create it
+			const response = await fetch('/instance', {
+				method: 'POST',
+				headers: { 'Content-Type': 'application/json' },
+				body: JSON.stringify({ instance: instanceData })
+			});
+			
+			if (!response.ok) {
+				const errorText = await response.text();
+				console.error('Failed to launch instance:', errorText);
+			} else {
+				// Auto-select the launched model only if no model is currently selected
+				if (!selectedChatModel()) {
+					setSelectedChatModel(modelId);
+				}
+				
+				// Scroll to the bottom of instances container to show the new instance
+				// Use multiple attempts to ensure DOM has updated with the new instance
+				const scrollToBottom = () => {
+					if (instancesContainerRef) {
+						instancesContainerRef.scrollTo({ top: instancesContainerRef.scrollHeight, behavior: 'smooth' });
+					}
+				};
+				setTimeout(scrollToBottom, 200);
+				setTimeout(scrollToBottom, 500);
+				setTimeout(scrollToBottom, 1000);
+			}
+		} catch (error) {
+			console.error('Error launching instance:', error);
+		} finally {
+			launchingModelId = null;
+		}
+	}
+
+	// Helper to extract model ID from download shard metadata
+	function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
+		const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+		if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+		
+		// Shard metadata is a tagged union: { PipelineShardMetadata: {...} } or { TensorShardMetadata: {...} }
+		const shardObj = shardMetadata as Record<string, unknown>;
+		const shardKeys = Object.keys(shardObj);
+		if (shardKeys.length !== 1) return null;
+		
+		const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+		if (!shardData) return null;
+		
+		// Model meta is nested: shard.model_meta.model_id
+		const modelMeta = shardData.model_meta ?? shardData.modelMeta;
+		if (!modelMeta || typeof modelMeta !== 'object') return null;
+		
+		const meta = modelMeta as Record<string, unknown>;
+		return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+	}
+
+	// Helper to parse download progress from payload
+	function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
+		const progress = payload.download_progress ?? payload.downloadProgress;
+		if (!progress || typeof progress !== 'object') return null;
+		
+		const prog = progress as Record<string, unknown>;
+		const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+		const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
+		const speed = (prog.speed as number) ?? 0;
+		const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+		const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+		const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+		
+		const files: DownloadProgress['files'] = [];
+		const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+		for (const [fileName, fileData] of Object.entries(filesObj)) {
+			if (!fileData || typeof fileData !== 'object') continue;
+			const fd = fileData as Record<string, unknown>;
+			const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+			const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+			files.push({
+				name: fileName,
+				totalBytes: fTotal,
+				downloadedBytes: fDownloaded,
+				speed: (fd.speed as number) ?? 0,
+				etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+				percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
+			});
+		}
+		
+		return {
+			totalBytes,
+			downloadedBytes,
+			speed,
+			etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+			percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+			completedFiles,
+			totalFiles,
+			files
+		};
+	}
+
+	// Helper to get download status for a model (checks all downloads for matching model ID)
+	function getModelDownloadStatus(modelId: string): { 
+		isDownloading: boolean; 
+		progress: DownloadProgress | null;
+		perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
+	} {
+		if (!downloadsData || Object.keys(downloadsData).length === 0) {
+			return { isDownloading: false, progress: null, perNode: [] };
+		}
+
+		let totalBytes = 0;
+		let downloadedBytes = 0;
+		let totalSpeed = 0;
+		let completedFiles = 0;
+		let totalFiles = 0;
+		let isDownloading = false;
+		const allFiles: DownloadProgress['files'] = [];
+		const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
+
+		// Check all nodes for downloads matching this model
+		for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
+			if (!Array.isArray(nodeDownloads)) continue;
+
+			for (const downloadWrapped of nodeDownloads) {
+				if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+				
+				const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+				if (keys.length !== 1) continue;
+				
+				const downloadKind = keys[0];
+				const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+				
+				if (downloadKind !== 'DownloadOngoing') continue;
+				if (!downloadPayload) continue;
+				
+				const downloadModelId = extractModelIdFromDownload(downloadPayload);
+				
+				// Match if the model ID contains or equals the requested model
+				// (handles cases like "mlx-community/Meta-Llama..." matching)
+				if (!downloadModelId || !downloadModelId.includes(modelId.split('/').pop() || modelId)) {
+					// Try exact match or partial match
+					if (downloadModelId !== modelId) continue;
+				}
+				
+				isDownloading = true;
+				
+				const progress = parseDownloadProgress(downloadPayload);
+				if (progress) {
+					totalBytes += progress.totalBytes;
+					downloadedBytes += progress.downloadedBytes;
+					totalSpeed += progress.speed;
+					completedFiles += progress.completedFiles;
+					totalFiles += progress.totalFiles;
+					allFiles.push(...progress.files);
+
+					const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+					perNode.push({ nodeId, nodeName, progress });
+				}
+			}
+		}
+
+		if (!isDownloading) {
+			return { isDownloading: false, progress: null, perNode: [] };
+		}
+
+		return {
+			isDownloading: true,
+			progress: {
+				totalBytes,
+				downloadedBytes,
+				speed: totalSpeed,
+				etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0,
+				percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+				completedFiles,
+				totalFiles,
+				files: allFiles
+			},
+			perNode
+		};
+	}
+
+	// Debug: Log downloads data when it changes
+	$effect(() => {
+		if (downloadsData && Object.keys(downloadsData).length > 0) {
+			console.log('[Download Debug] Current downloads:', downloadsData);
+		}
+	});
+
+	// Helper to get download status for an instance
+	function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): { 
+		isDownloading: boolean; 
+		progress: DownloadProgress | null; 
+		statusText: string;
+		perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
+	} {
+		if (!downloadsData || Object.keys(downloadsData).length === 0) {
+			return { isDownloading: false, progress: null, statusText: 'RUNNING', perNode: [] };
+		}
+
+		// Unwrap the instance
+		const [instanceTag, instance] = getTagged(instanceWrapped);
+		if (!instance || typeof instance !== 'object') {
+			return { isDownloading: false, progress: null, statusText: 'UNKNOWN', perNode: [] };
+		}
+
+		const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string>; runnerToShard?: Record<string, unknown>; modelId?: string } };
+		const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+		const instanceModelId = inst.shardAssignments?.modelId;
+
+		// Build reverse mapping: runnerId -> nodeId
+		const runnerToNode: Record<string, string> = {};
+		for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
+			runnerToNode[runnerId] = nodeId;
+		}
+
+		let totalBytes = 0;
+		let downloadedBytes = 0;
+		let totalSpeed = 0;
+		let completedFiles = 0;
+		let totalFiles = 0;
+		let isDownloading = false;
+		const allFiles: DownloadProgress['files'] = [];
+		const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
+
+		// Check downloads for nodes that are part of this instance
+		for (const runnerId of Object.keys(runnerToShard)) {
+			const nodeId = runnerToNode[runnerId];
+			if (!nodeId) continue;
+
+			const nodeDownloads = downloadsData[nodeId];
+			if (!Array.isArray(nodeDownloads)) continue;
+
+			for (const downloadWrapped of nodeDownloads) {
+				if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+				
+				const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+				if (keys.length !== 1) continue;
+				
+				const downloadKind = keys[0];
+				const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+				
+				if (downloadKind !== 'DownloadOngoing') continue;
+				if (!downloadPayload) continue;
+
+				// Check if this download is for this instance's model
+				const downloadModelId = extractModelIdFromDownload(downloadPayload);
+				if (instanceModelId && downloadModelId && downloadModelId === instanceModelId) {
+					isDownloading = true;
+					
+					const progress = parseDownloadProgress(downloadPayload);
+					if (progress) {
+						totalBytes += progress.totalBytes;
+						downloadedBytes += progress.downloadedBytes;
+						totalSpeed += progress.speed;
+						completedFiles += progress.completedFiles;
+						totalFiles += progress.totalFiles;
+						allFiles.push(...progress.files);
+
+						const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+						perNode.push({ nodeId, nodeName, progress });
+					}
+				}
+			}
+		}
+
+		if (!isDownloading) {
+			// Check runner status for other states
+			const statusInfo = deriveInstanceStatus(instanceWrapped);
+			return { isDownloading: false, progress: null, statusText: statusInfo.statusText, perNode: [] };
+		}
+
+		return {
+			isDownloading: true,
+			progress: {
+				totalBytes,
+				downloadedBytes,
+				speed: totalSpeed,
+				etaMs: totalSpeed > 0 ? ((totalBytes - downloadedBytes) / totalSpeed) * 1000 : 0,
+				percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+				completedFiles,
+				totalFiles,
+				files: allFiles
+			},
+			statusText: 'DOWNLOADING',
+			perNode
+		};
+	}
+
+	// Derive instance status from runners
+	// Get color class for a status
+	function getStatusColor(statusText: string): string {
+		switch (statusText) {
+			case 'FAILED': return 'text-red-400';
+			case 'DOWNLOADING': return 'text-blue-400';
+			case 'LOADING': 
+			case 'WARMING UP': 
+			case 'WAITING': return 'text-yellow-400';
+			case 'RUNNING': return 'text-teal-400';
+			case 'READY': 
+			case 'LOADED': return 'text-green-400';
+			default: return 'text-exo-light-gray';
+		}
+	}
+
+	function deriveInstanceStatus(instanceWrapped: unknown): { statusText: string; statusClass: string } {
+		const [, instance] = getTagged(instanceWrapped);
+		if (!instance || typeof instance !== 'object') {
+			return { statusText: 'UNKNOWN', statusClass: 'inactive' };
+		}
+		
+		const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
+		const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
+
+		const statuses = runnerIds
+			.map(rid => {
+				const r = runnersData[rid];
+				if (!r) return null;
+				const [kind] = getTagged(r);
+				const statusMap: Record<string, string> = {
+					RunnerWaitingForModel: 'WaitingForModel',
+					RunnerLoading: 'Loading',
+					RunnerLoaded: 'Loaded',
+					RunnerWarmingUp: 'WarmingUp',
+					RunnerReady: 'Ready',
+					RunnerRunning: 'Running',
+					RunnerFailed: 'Failed',
+				};
+				return kind ? statusMap[kind] || null : null;
+			})
+			.filter((s): s is string => s !== null);
+
+		const has = (s: string) => statuses.includes(s);
+
+		if (statuses.length === 0) return { statusText: 'UNKNOWN', statusClass: 'inactive' };
+		if (has('Failed')) return { statusText: 'FAILED', statusClass: 'failed' };
+		if (has('Loading')) return { statusText: 'LOADING', statusClass: 'starting' };
+		if (has('WarmingUp')) return { statusText: 'WARMING UP', statusClass: 'starting' };
+		if (has('Running')) return { statusText: 'RUNNING', statusClass: 'running' };
+		if (has('Ready')) return { statusText: 'READY', statusClass: 'loaded' };
+		if (has('Loaded')) return { statusText: 'LOADED', statusClass: 'loaded' };
+		if (has('WaitingForModel')) return { statusText: 'WAITING', statusClass: 'starting' };
+
+		return { statusText: 'RUNNING', statusClass: 'active' };
+	}
+
+	function getBytes(value: unknown): number {
+		if (typeof value === 'number') return value;
+		if (value && typeof value === 'object') {
+			const v = value as Record<string, unknown>;
+			if (typeof v.in_bytes === 'number') return v.in_bytes;
+			if (typeof v.inBytes === 'number') return v.inBytes;
+		}
+		return 0;
+	}
+
+	async function deleteInstance(instanceId: string) {
+		if (!confirm(`Delete instance ${instanceId.slice(0, 8)}...?`)) return;
+		
+		try {
+			const response = await fetch(`/instance/${instanceId}`, {
+				method: 'DELETE',
+				headers: { 'Content-Type': 'application/json' }
+			});
+			
+			if (!response.ok) {
+				console.error('Failed to delete instance:', response.status);
+			}
+		} catch (error) {
+			console.error('Error deleting instance:', error);
+		}
+	}
+
+	// Helper to unwrap tagged unions like { MlxRingInstance: {...} }
+	function getTagged(obj: unknown): [string | null, unknown] {
+		if (!obj || typeof obj !== 'object') return [null, null];
+		const keys = Object.keys(obj as Record<string, unknown>);
+		if (keys.length === 1) {
+			return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
+		}
+		return [null, null];
+	}
+
+	// Get model ID from an instance
+	function getInstanceModelId(instanceWrapped: unknown): string {
+		const [, instance] = getTagged(instanceWrapped);
+		if (!instance || typeof instance !== 'object') return 'Unknown';
+		const inst = instance as { shardAssignments?: { modelId?: string } };
+		return inst.shardAssignments?.modelId || 'Unknown Model';
+	}
+
+	// Get instance details: type (MLX Ring/IBV), sharding (Pipeline/Tensor), and node names
+	function getInstanceInfo(instanceWrapped: unknown): { 
+		instanceType: string; 
+		sharding: string; 
+		nodeNames: string[];
+		nodeIds: string[];
+		nodeCount: number;
+	} {
+		const [instanceTag, instance] = getTagged(instanceWrapped);
+		if (!instance || typeof instance !== 'object') {
+			return { instanceType: 'Unknown', sharding: 'Unknown', nodeNames: [], nodeIds: [], nodeCount: 0 };
+		}
+		
+		// Instance type from tag
+		let instanceType = 'Unknown';
+		if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
+		else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
+		
+		const inst = instance as { 
+			shardAssignments?: { 
+				nodeToRunner?: Record<string, string>; 
+				runnerToShard?: Record<string, unknown>;
+			} 
+		};
+		
+		// Sharding strategy from first shard
+		let sharding = 'Unknown';
+		const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+		const firstShardWrapped = Object.values(runnerToShard)[0];
+		if (firstShardWrapped) {
+			const [shardTag] = getTagged(firstShardWrapped);
+			if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
+			else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
+			else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
+		}
+		
+		// Node names from topology
+		const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+		const nodeIds = Object.keys(nodeToRunner);
+		const nodeNames = nodeIds.map(nodeId => {
+			const node = data?.nodes?.[nodeId];
+			return node?.friendly_name || nodeId.slice(0, 8);
+		});
+		
+		return { instanceType, sharding, nodeNames, nodeIds, nodeCount: nodeIds.length };
+	}
+
+	function formatLastUpdate(): string {
+		if (!update) return 'ACQUIRING...';
+		const seconds = Math.floor((Date.now() - update) / 1000);
+		if (seconds < 5) return 'LIVE';
+		return `${seconds}s AGO`;
+	}
+
+	function formatBytes(bytes: number, decimals = 2): string {
+		if (!bytes || bytes === 0) return '0 B';
+		const k = 1024;
+		const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
+		const i = Math.floor(Math.log(bytes) / Math.log(k));
+		return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
+	}
+
+	function formatSpeed(bps: number): string {
+		if (!bps || bps <= 0) return '0 B/s';
+		return formatBytes(bps, 1) + '/s';
+	}
+
+	function getNodeLabel(nodeId: string): string {
+		const node = data?.nodes?.[nodeId];
+		return node?.friendly_name || nodeId.slice(0, 8);
+	}
+
+	function getInterfaceLabel(nodeId: string, ip?: string): { label: string; missing: boolean } {
+		if (!ip) return { label: '?', missing: true };
+		const node = data?.nodes?.[nodeId];
+		if (!node) return { label: '?', missing: true };
+
+		// Prefer explicit network_interfaces from NodePerformanceProfile
+		const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+			(iface.addresses || []).some((addr) => addr === ip)
+		);
+		if (matchFromInterfaces?.name) {
+			return { label: `${matchFromInterfaces.name} on ${getNodeLabel(nodeId)}`, missing: false };
+		}
+
+		// Fallback to derived ip_to_interface map
+		const mapped = node.ip_to_interface?.[ip];
+		if (mapped && mapped.trim().length > 0) {
+			return { label: `${mapped} on ${getNodeLabel(nodeId)}`, missing: false };
+		}
+
+		return { label: '?', missing: true };
+	}
+
+	function getOrderedRunnerNodes(instance: Record<string, unknown>, shardType: 'Pipeline' | 'Tensor') {
+		const runnerToShard = (instance.shardAssignments as { runnerToShard?: Record<string, unknown> } | undefined)?.runnerToShard || {};
+		const nodeToRunner = (instance.shardAssignments as { nodeToRunner?: Record<string, string> } | undefined)?.nodeToRunner || {};
+		const runnerEntries = Object.entries(runnerToShard).map(([runnerId, shardWrapped]) => {
+			const [tag, shard] = getTagged(shardWrapped);
+			const meta = (shard as { modelMeta?: { worldSize?: number; nLayers?: number; deviceRank?: number } } | undefined);
+			const deviceRank = (meta?.deviceRank as number | undefined) ?? 0;
+			return { runnerId, tag, deviceRank };
+		});
+
+		const ordered = runnerEntries
+			.filter(r => (shardType === 'Pipeline' ? r.tag === 'PipelineShardMetadata' : r.tag === 'TensorShardMetadata'))
+			.sort((a, b) => a.deviceRank - b.deviceRank)
+			.map((r, idx) => {
+				const nodeId = Object.entries(nodeToRunner).find(([, rid]) => rid === r.runnerId)?.[0];
+				return { nodeId, runnerId: r.runnerId, order: idx };
+			})
+			.filter(item => item.nodeId);
+
+		return ordered as Array<{ nodeId: string; runnerId: string; order: number }>;
+	}
+
+	function pickHost(hosts?: Array<{ ip: string; port: number }>): { ip: string; port: number } | null {
+		if (!hosts || hosts.length === 0) return null;
+		const scored = hosts
+			.filter(h => h.ip && h.ip !== '0.0.0.0' && h.port && h.port > 0)
+			.map(h => {
+				const ip = h.ip;
+				const score = ip.startsWith('10.') || ip.startsWith('172.') || ip.startsWith('192.168') ? 3
+					: ip.startsWith('169.254') ? 2
+					: 1;
+				return { host: h, score };
+			});
+		if (scored.length === 0) return null;
+		scored.sort((a, b) => b.score - a.score);
+		return scored[0].host;
+	}
+
+	function getInstanceConnections(instanceWrapped: unknown): Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> {
+		const [instanceTag, instance] = getTagged(instanceWrapped);
+		if (!instance || typeof instance !== 'object') return [];
+
+		// Jaccl (RDMA) – show RDMA interfaces from ibvDevices
+		if (instanceTag === 'MlxJacclInstance') {
+			const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Tensor');
+			const ibvDevices = (instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices || [];
+			const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
+
+			for (let i = 0; i < ordered.length; i++) {
+				for (let j = i + 1; j < ordered.length; j++) {
+					const iface = ibvDevices[i]?.[j] ?? ibvDevices[j]?.[i] ?? null;
+					if (!iface) continue;
+					const fromId = ordered[i].nodeId;
+					const toId = ordered[j].nodeId;
+					rows.push({
+						from: getNodeLabel(fromId),
+						to: getNodeLabel(toId),
+						ip: iface,
+						ifaceLabel: `RDMA ${iface}`,
+						missingIface: false
+					});
+				}
+			}
+			return rows;
+		}
+
+		// Ring – derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
+		if (instanceTag === 'MlxRingInstance') {
+			const ordered = getOrderedRunnerNodes(instance as Record<string, unknown>, 'Pipeline');
+			const hostsByNode = (instance as { hostsByNode?: Record<string, Array<{ ip: string; port: number }>> }).hostsByNode || {};
+			const rows: Array<{ from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean }> = [];
+			if (ordered.length === 0) return rows;
+
+			for (let idx = 0; idx < ordered.length; idx++) {
+				const current = ordered[idx];
+				const next = ordered[(idx + 1) % ordered.length];
+				const host = pickHost(hostsByNode[next.nodeId]);
+				const ip = host ? `${host.ip}:${host.port}` : '?';
+				const ifaceInfo = host ? getInterfaceLabel(next.nodeId, host.ip) : { label: '?', missing: true };
+				rows.push({
+					from: getNodeLabel(current.nodeId),
+					to: getNodeLabel(next.nodeId),
+					ip,
+					ifaceLabel: ifaceInfo.label,
+					missingIface: ifaceInfo.missing
+				});
+			}
+			return rows;
+		}
+
+		return [];
+	}
+
+	function formatEta(ms: number): string {
+		if (!ms || ms <= 0) return '--';
+		const totalSeconds = Math.round(ms / 1000);
+		const s = totalSeconds % 60;
+		const m = Math.floor(totalSeconds / 60) % 60;
+		const h = Math.floor(totalSeconds / 3600);
+		if (h > 0) return `${h}h ${m}m`;
+		if (m > 0) return `${m}m ${s}s`;
+		return `${s}s`;
+	}
+
+	function handleNewChat() {
+		createConversation();
+	}
+
+	function handleGoHome() {
+		clearChat();
+	}
+
+	// Slider drag handlers
+	function handleSliderDrag(clientX: number) {
+		if (!sliderTrackElement || availableMinNodes <= 1) return;
+		
+		const rect = sliderTrackElement.getBoundingClientRect();
+		const percentage = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
+		const rawValue = Math.round(percentage * (availableMinNodes - 1)) + 1;
+		const clampedValue = Math.max(1, Math.min(availableMinNodes, rawValue));
+		
+		// Find nearest valid value
+		const validCounts = validMinNodeCounts();
+		if (validCounts.has(clampedValue)) {
+			selectedMinNodes = clampedValue;
+		} else {
+			// Find nearest valid value
+			let nearest = clampedValue;
+			let minDist = Infinity;
+			for (const v of validCounts) {
+				const dist = Math.abs(v - clampedValue);
+				if (dist < minDist) {
+					minDist = dist;
+					nearest = v;
+				}
+			}
+			if (validCounts.size > 0) {
+				selectedMinNodes = nearest;
+			}
+		}
+	}
+
+	function handleSliderMouseDown(event: MouseEvent) {
+		isDraggingSlider = true;
+		handleSliderDrag(event.clientX);
+	}
+
+	function handleSliderMouseMove(event: MouseEvent) {
+		if (isDraggingSlider) {
+			handleSliderDrag(event.clientX);
+		}
+	}
+
+	function handleSliderMouseUp() {
+		isDraggingSlider = false;
+	}
+
+	// Handle touch events for mobile
+	function handleSliderTouchStart(event: TouchEvent) {
+		isDraggingSlider = true;
+		if (event.touches.length > 0) {
+			handleSliderDrag(event.touches[0].clientX);
+		}
+	}
+
+	function handleSliderTouchMove(event: TouchEvent) {
+		if (isDraggingSlider && event.touches.length > 0) {
+			event.preventDefault();
+			handleSliderDrag(event.touches[0].clientX);
+		}
+	}
+
+	function handleSliderTouchEnd() {
+		isDraggingSlider = false;
+	}
+
+	const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
+	const instanceCount = $derived(Object.keys(instanceData).length);
+	
+	// Helper to get the number of nodes in a placement preview
+	function getPreviewNodeCount(preview: PlacementPreview): number {
+		if (!preview.memory_delta_by_node) return 0;
+		// Count nodes that have non-zero memory delta (i.e. nodes actually used)
+		return Object.entries(preview.memory_delta_by_node).filter(([_, delta]) => delta > 0).length;
+	}
+	
+	// Available min nodes options based on topology (like old dashboard)
+	const availableMinNodes = $derived(Math.max(1, nodeCount));
+	
+	// Compute which min node values have valid previews for the current model/sharding/instance type
+	// A minNodes value N is valid if there exists a placement with nodeCount >= N
+	// Note: previewsData already contains previews for the selected model (fetched via API)
+	const validMinNodeCounts = $derived(() => {
+		if (!selectedModelId || previewsData.length === 0) {
+			// If no model selected or no previews, allow all node counts (UI shows all as clickable)
+			return new Set(Array.from({ length: availableMinNodes }, (_, i) => i + 1));
+		}
+		
+		// Find the max node count among valid placements for this model/sharding/instance
+		// (model_id filter not needed since previewsData is already for selected model)
+		let maxValidNodes = 0;
+		for (const preview of previewsData) {
+			if (preview.sharding !== selectedSharding) continue;
+			if (!matchesSelectedRuntime(preview.instance_meta)) continue;
+			if (preview.error !== null) continue;
+			if (!preview.memory_delta_by_node) continue;
+			
+			const previewNodes = getPreviewNodeCount(preview);
+			if (previewNodes > maxValidNodes) {
+				maxValidNodes = previewNodes;
+			}
+		}
+		
+		// All values from 1 to maxValidNodes are valid (since there's a placement with >= that many nodes)
+		if (maxValidNodes === 0) return new Set<number>();
+		return new Set(Array.from({ length: maxValidNodes }, (_, i) => i + 1));
+	});
+	
+	// Get ALL filtered previews based on current settings (matching minimum nodes)
+	// Note: previewsData already contains previews for the selected model (fetched via API)
+	// We filter by sharding/instance type and min nodes, returning ALL eligible previews
+	const filteredPreviews = $derived(() => {
+		if (!selectedModelId || previewsData.length === 0) return [];
+		
+		// Find previews matching sharding/instance type (model_id filter not needed since previewsData is already for selected model)
+		const matchingPreviews = previewsData.filter((p: PlacementPreview) => 
+			p.sharding === selectedSharding && 
+			matchesSelectedRuntime(p.instance_meta) && 
+			p.error === null &&
+			p.memory_delta_by_node !== null
+		);
+		
+		// Filter to previews with node count >= selectedMinNodes, sorted by node count (ascending)
+		return matchingPreviews
+			.filter((p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes)
+			.sort((a: PlacementPreview, b: PlacementPreview) => getPreviewNodeCount(a) - getPreviewNodeCount(b));
+	});
+	
+	// Get the first filtered preview (for launch function compatibility)
+	const filteredPreview = $derived(() => filteredPreviews()[0] ?? null);
+	
+	// Auto-update selectedMinNodes when node count changes (default to 1 = show all placements)
+	$effect(() => {
+		const maxNodes = availableMinNodes;
+		if (!minNodesInitialized && maxNodes > 0) {
+			// On initial load, default to 1 (minimum) to show all valid placements
+			selectedMinNodes = 1;
+			minNodesInitialized = true;
+		} else if (selectedMinNodes > maxNodes) {
+			// If current selection exceeds available nodes, cap it
+			selectedMinNodes = maxNodes;
+		}
+	});
+	
+	// Auto-adjust selectedMinNodes to a valid value when it becomes invalid
+	$effect(() => {
+		const valid = validMinNodeCounts();
+		if (valid.size > 0 && !valid.has(selectedMinNodes)) {
+			// Find the smallest valid count >= current selection, or the largest valid count
+			const validArray = Array.from(valid).sort((a, b) => a - b);
+			const nextValid = validArray.find(n => n >= selectedMinNodes) ?? validArray[validArray.length - 1];
+			if (nextValid !== undefined) {
+				selectedMinNodes = nextValid;
+			}
+		}
+	});
+	
+	// Calculate total memory usage across all nodes
+	const clusterMemory = $derived(() => {
+		if (!data) return { used: 0, total: 0 };
+		return Object.values(data.nodes).reduce((acc, n) => {
+			const total = n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
+			const used = n.macmon_info?.memory?.ram_usage ?? 0;
+			return { used: acc.used + used, total: acc.total + total };
+		}, { used: 0, total: 0 });
+	});
+	
+</script>
+
+<!-- Global event listeners for slider dragging -->
+<svelte:window 
+	onmousemove={handleSliderMouseMove}
+	onmouseup={handleSliderMouseUp}
+	ontouchmove={handleSliderTouchMove}
+	ontouchend={handleSliderTouchEnd}
+/>
+
+<div class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden">
+	<!-- Scanline overlay -->
+	<div class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"></div>
+	
+	<!-- Shooting Stars Background - one every ~15s -->
+	<div class="shooting-stars">
+		<div class="shooting-star" style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"></div>
+		<div class="shooting-star" style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"></div>
+		<div class="shooting-star" style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"></div>
+	</div>
+
+	<HeaderNav showHome={chatStarted} onHome={handleGoHome} />
+
+	<!-- Main Content -->
+	<main class="flex-1 flex overflow-hidden relative">
+		<!-- Left: Conversation History Sidebar (always visible) -->
+		<div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
+			<ChatSidebar class="h-full" />
+		</div>
+
+		{#if !chatStarted}
+			<!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
+			<div class="flex-1 flex overflow-visible relative" in:fade={{ duration: 300 }} out:fade={{ duration: 200 }}>
+				
+				<!-- Center: MAIN TOPOLOGY DISPLAY -->
+				<div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
+					
+					<!-- Topology Container - Takes most of the space -->
+					<div class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden">
+						
+						<!-- The main topology graph - full container -->
+						<TopologyGraph class="w-full h-full" highlightedNodes={highlightedNodes()} />
+					</div>
+					
+					<!-- Chat Input - Below topology -->
+					<div class="px-4 pt-6 pb-8">
+						<div class="max-w-3xl mx-auto">
+							<ChatForm 
+								placeholder="Ask anything" 
+								showHelperText={false}
+								showModelSelector={true}
+							/>
+						</div>
+					</div>
+				</div>
+				
+				<!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+				<aside class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0">
+					<!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
+					{#if instanceCount > 0}
+					<div class="p-4 flex-shrink-0">
+						<!-- Panel Header -->
+						<div class="flex items-center gap-2 mb-4">
+							<div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
+							<h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
+							<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+						</div>
+						
+						<div 
+							bind:this={instancesContainerRef}
+							class="max-h-72 space-y-3 overflow-y-auto"
+						>
+								{#each Object.entries(instanceData) as [id, instance]}
+									{@const downloadInfo = getInstanceDownloadStatus(id, instance)}
+									{@const statusText = downloadInfo.statusText}
+									{@const isDownloading = downloadInfo.isDownloading}
+									{@const isFailed = statusText === 'FAILED'}
+									{@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
+									{@const isReady = statusText === 'READY' || statusText === 'LOADED'}
+									{@const isRunning = statusText === 'RUNNING'}
+								<!-- Instance Card -->
+								{@const instanceModelId = getInstanceModelId(instance)}
+								{@const instanceInfo = getInstanceInfo(instance)}
+								{@const instanceConnections = getInstanceConnections(instance)}
+								<div 
+									class="relative group cursor-pointer"
+									role="button"
+									tabindex="0"
+									onmouseenter={() => hoveredInstanceId = id}
+									onmouseleave={() => hoveredInstanceId = null}
+									onclick={() => {
+										if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+											setSelectedChatModel(instanceModelId);
+										}
+									}}
+									onkeydown={(e) => {
+										if (e.key === 'Enter' || e.key === ' ') {
+											if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+												setSelectedChatModel(instanceModelId);
+											}
+										}
+									}}
+								>
+									<!-- Corner accents -->
+									<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+									<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+									<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+									<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+									
+									<div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
+										
+										<div class="flex justify-between items-start mb-2 pl-2">
+											<div class="flex items-center gap-2">
+												<div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
+												<span class="text-exo-light-gray font-mono text-sm tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
+											</div>
+											<button 
+												onclick={() => deleteInstance(id)}
+												class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+											>
+												DELETE
+											</button>
+											</div>
+											<div class="pl-2">
+												<div class="text-exo-yellow text-xs font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
+												<div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
+												{#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
+													<a
+														class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
+														href={`https://huggingface.co/${instanceModelId}`}
+														target="_blank"
+														rel="noreferrer noopener"
+														aria-label="View model on Hugging Face"
+													>
+														<span>Hugging Face</span>
+														<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+															<path d="M14 3h7v7"/>
+															<path d="M10 14l11-11"/>
+															<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+														</svg>
+													</a>
+												{/if}
+												{#if instanceInfo.nodeNames.length > 0}
+													<div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
+												{/if}
+												{#if debugEnabled && instanceConnections.length > 0}
+													<div class="mt-2 space-y-1">
+														{#each instanceConnections as conn}
+															<div class="text-[11px] leading-snug font-mono text-white/70">
+																<span>{conn.from} -> {conn.to}: {conn.ip}</span>
+																<span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
+															</div>
+														{/each}
+													</div>
+												{/if}
+												
+												<!-- Download Progress -->
+												{#if downloadInfo.isDownloading && downloadInfo.progress}
+													<div class="mt-2 space-y-1">
+														<div class="flex justify-between text-xs font-mono">
+															<span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
+															<span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
+														</div>
+														<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+															<div 
+																class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+																style="width: {downloadInfo.progress.percentage}%"
+															></div>
+														</div>
+														<div class="flex justify-between text-xs font-mono text-exo-light-gray">
+															<span>{formatSpeed(downloadInfo.progress.speed)}</span>
+															<span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
+															<span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
+														</div>
+													</div>
+													{#if downloadInfo.perNode.length > 0}
+														<div class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1">
+															{#each downloadInfo.perNode as nodeProg}
+																{@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.percentage))}
+																{@const isExpanded = instanceDownloadExpandedNodes.has(nodeProg.nodeId)}
+																<div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
+																	<button
+																		type="button"
+																		class="w-full text-left space-y-1.5"
+																		onclick={() => toggleInstanceDownloadDetails(nodeProg.nodeId)}
+																	>
+																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
+																			<span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
+																			<span class="flex items-center gap-1 text-blue-300">
+																				{nodePercent.toFixed(1)}%
+																				<svg class="w-3 h-3 text-exo-light-gray" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
+																					<path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
+																				</svg>
+																			</span>
+																		</div>
+																		<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+																			<div 
+																				class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+																				style="width: {nodePercent.toFixed(1)}%"
+																			></div>
+																		</div>
+																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray">
+																			<span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
+																			<span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
+																		</div>
+																	</button>
+
+																	{#if isExpanded}
+																		<div class="mt-2 space-y-1.5">
+																			{#if nodeProg.progress.files.length === 0}
+																				<div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
+																			{:else}
+																				{#each nodeProg.progress.files as f}
+																					{@const filePercent = Math.min(100, Math.max(0, f.percentage ?? 0))}
+																					<div class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2">
+																						<div class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90">
+																							<span class="truncate pr-2">{f.name}</span>
+																							<span class="text-white/80">{filePercent.toFixed(1)}%</span>
+																						</div>
+																						<div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1">
+																							<div 
+																								class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow/70 transition-all duration-300"
+																								style="width: {filePercent.toFixed(1)}%"
+																							></div>
+																						</div>
+																						<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
+																							<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+																							<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+																						</div>
+																					</div>
+																				{/each}
+																			{/if}
+																		</div>
+																	{/if}
+																</div>
+															{/each}
+														</div>
+													{/if}
+													<div class="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
+												{:else}
+													<div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
+												{/if}
+											</div>
+										</div>
+									</div>
+								{/each}
+						</div>
+					</div>
+					{/if}
+					
+					<!-- Models Panel - Scrollable -->
+					<div class="p-4 flex-1 overflow-y-auto">
+						<!-- Panel Header -->
+						<div class="flex items-center gap-2 mb-3 flex-shrink-0">
+							<div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
+							<h3 class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase">Launch Instance</h3>
+							<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+							<span class="text-sm text-white/70 font-mono">{models.length} models</span>
+						</div>
+						
+						<!-- Model Dropdown (Custom) -->
+						<div class="flex-shrink-0 mb-3 relative">
+							<button
+								type="button"
+								onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
+								class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
+							>
+								{#if selectedModelId}
+									{@const foundModel = models.find(m => m.id === selectedModelId)}
+									{#if foundModel}
+										{@const sizeGB = getModelSizeGB(foundModel)}
+										<span class="flex items-center justify-between gap-2 w-full pr-4">
+											<span class="text-exo-light-gray truncate">{foundModel.name || foundModel.id}</span>
+											<span class="text-white/50 text-xs flex-shrink-0">{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB</span>
+										</span>
+									{:else}
+										<span class="text-exo-light-gray">{selectedModelId}</span>
+									{/if}
+								{:else}
+									<span class="text-white/50">— SELECT MODEL —</span>
+								{/if}
+							</button>
+							<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
+								<svg class="w-4 h-4 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+									<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
+								</svg>
+							</div>
+							
+							{#if isModelDropdownOpen}
+								<!-- Backdrop to close dropdown -->
+								<button 
+									type="button"
+									class="fixed inset-0 z-40 cursor-default" 
+									onclick={() => isModelDropdownOpen = false}
+									aria-label="Close dropdown"
+								></button>
+								
+								<!-- Dropdown Panel -->
+							<div class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto">
+								<!-- Search within dropdown -->
+								<div class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2">
+										<input
+											type="text"
+											placeholder="Search models..."
+											bind:value={modelDropdownSearch}
+											class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
+										/>
+									</div>
+									
+									<!-- Options -->
+									<div class="py-1">
+										{#each sortedModels().filter(m => 
+											!modelDropdownSearch || 
+											(m.name || m.id).toLowerCase().includes(modelDropdownSearch.toLowerCase())
+										) as model}
+											{@const sizeGB = getModelSizeGB(model)}
+											{@const modelCanFit = hasEnoughMemory(model)}
+											<button
+												type="button"
+												onclick={() => {
+													if (modelCanFit) {
+														selectPreviewModel(model.id);
+														isModelDropdownOpen = false;
+														modelDropdownSearch = '';
+													}
+												}}
+												disabled={!modelCanFit}
+												class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {
+													selectedModelId === model.id 
+														? 'bg-transparent text-exo-yellow cursor-pointer' 
+														: modelCanFit 
+															? 'text-white/80 hover:text-exo-yellow cursor-pointer' 
+															: 'text-white/30 cursor-default'
+												}"
+											>
+												<span class="truncate">{model.name || model.id}</span>
+												<span class="flex-shrink-0 text-xs {modelCanFit ? 'text-white/50' : 'text-red-400/60'}">
+													{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB
+												</span>
+											</button>
+										{:else}
+											<div class="px-3 py-2 text-xs text-white/50 font-mono">No models found</div>
+										{/each}
+									</div>
+								</div>
+							{/if}
+						</div>
+						
+						<!-- Configuration Options -->
+						<div class="flex-shrink-0 mb-4 space-y-3">
+							<!-- Sharding -->
+							<div>
+								<div class="text-xs text-white/70 font-mono mb-2">Sharding:</div>
+								<div class="flex gap-2">
+									<button 
+										onclick={() => selectedSharding = 'Pipeline'}
+										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Pipeline' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+									>
+										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Pipeline' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+											{#if selectedSharding === 'Pipeline'}
+												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+											{/if}
+										</span>
+										Pipeline
+									</button>
+									<button 
+										onclick={() => selectedSharding = 'Tensor'}
+										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding === 'Tensor' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+									>
+										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Tensor' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+											{#if selectedSharding === 'Tensor'}
+												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+											{/if}
+										</span>
+										Tensor
+									</button>
+								</div>
+							</div>
+							
+							<!-- Instance Type -->
+							<div>
+								<div class="text-xs text-white/70 font-mono mb-2">Instance Type:</div>
+								<div class="flex gap-2">
+									<button 
+										onclick={() => selectedInstanceType = 'MlxRing'}
+										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxRing' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+									>
+										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxRing' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+											{#if selectedInstanceType === 'MlxRing'}
+												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+											{/if}
+										</span>
+										MLX Ring
+									</button>
+									<button 
+										onclick={() => selectedInstanceType = 'MlxIbv'}
+										class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxIbv' ? 'bg-transparent text-exo-yellow border-exo-yellow' : 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
+									>
+										<span class="w-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxIbv' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
+											{#if selectedInstanceType === 'MlxIbv'}
+												<span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+											{/if}
+										</span>
+										MLX RDMA
+									</button>
+								</div>
+							</div>
+							
+							<!-- Minimum Nodes (discrete slider with drag support) -->
+							<div>
+								<div class="text-xs text-white/70 font-mono mb-2">Minimum Nodes:</div>
+								<!-- Discrete slider track with drag support -->
+								<!-- svelte-ignore a11y_no_static_element_interactions -->
+								<div 
+									bind:this={sliderTrackElement}
+									class="relative h-16 cursor-pointer select-none px-2 pr-6"
+									onmousedown={handleSliderMouseDown}
+									ontouchstart={handleSliderTouchStart}
+								>
+									<!-- Track background - extends full width to align with edge dots -->
+									<div class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"></div>
+									<!-- Active track (fills up to selected) -->
+									{#if availableMinNodes > 1}
+										<div 
+											class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
+											style="width: {((selectedMinNodes - 1) / (availableMinNodes - 1)) * 100}%"
+										></div>
+									{/if}
+									<!-- Dots and labels for each node count -->
+									{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
+										{@const isValid = validMinNodeCounts().has(n)}
+										{@const isSelected = selectedMinNodes === n}
+										{@const position = availableMinNodes > 1 ? ((n - 1) / (availableMinNodes - 1)) * 100 : 50}
+										<div
+											class="absolute flex flex-col items-center pointer-events-none"
+											style="left: {position}%; top: 0; transform: translateX(-50%);"
+										>
+											<!-- Dot -->
+											<span 
+												class="rounded-full transition-all {isSelected 
+													? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]' 
+													: isValid 
+														? 'w-4 h-4 bg-exo-light-gray/70 mt-1' 
+														: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
+											></span>
+											<!-- Number label below dot -->
+											<span 
+												class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected 
+													? 'text-exo-yellow font-bold' 
+													: isValid 
+														? 'text-white/70' 
+														: 'text-white/30'}"
+											>{n}</span>
+										</div>
+									{/each}
+								</div>
+							</div>
+						</div>
+						
+						<!-- Selected Model Preview -->
+						<div class="space-y-3">
+							{#if models.length === 0}
+								<div class="text-center py-8">
+									<div class="text-xs text-white/70 font-mono tracking-wider uppercase">Loading models...</div>
+								</div>
+							{:else if loadingPreviews}
+								<div class="text-center py-8">
+									<div class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse">Loading preview...</div>
+								</div>
+							{:else}
+								{@const selectedModel = models.find(m => m.id === selectedModelId)}
+								{@const allPreviews = filteredPreviews()}
+								{#if selectedModel && allPreviews.length > 0}
+									{@const downloadStatus = getModelDownloadStatus(selectedModel.id)}
+									{@const tags = modelTags()[selectedModel.id] || []}
+									<div class="space-y-3">
+										{#each allPreviews as apiPreview, i}
+										<div
+											role="group"
+											onmouseenter={() => {
+												if (apiPreview.memory_delta_by_node) {
+													hoveredPreviewNodes = new Set(
+														Object.entries(apiPreview.memory_delta_by_node)
+															.filter(([, delta]) => (delta ?? 0) > 0)
+															.map(([nodeId]) => nodeId)
+													);
+												}
+											}}
+											onmouseleave={() => hoveredPreviewNodes = new Set()}
+										>
+											<ModelCard 
+												model={selectedModel}
+												isLaunching={launchingModelId === selectedModel.id}
+												{downloadStatus}
+												nodes={data?.nodes ?? {}}
+												sharding={apiPreview.sharding}
+												runtime={apiPreview.instance_meta}
+												onLaunch={() => launchInstance(selectedModel.id, apiPreview)}
+												{tags}
+												{apiPreview}
+												modelIdOverride={apiPreview.model_id}
+											/>
+										</div>
+										{/each}
+									</div>
+								{:else if selectedModel}
+									<div class="text-center py-4">
+										<div class="text-xs text-white/50 font-mono">No valid configurations for current settings</div>
+									</div>
+								{/if}
+							{/if}
+						</div>
+					</div>
+				</aside>
+				
+			</div>
+		{:else}
+			<!-- CHAT STATE: Chat + Mini-Map -->
+			<div class="flex-1 flex overflow-hidden">
+				<!-- Chat Area -->
+				<div 
+					class="flex-1 flex flex-col min-w-0 overflow-hidden"
+					in:fade={{ duration: 300, delay: 100 }}
+				>
+					<div class="flex-1 overflow-y-auto px-8 py-6" bind:this={chatScrollRef}>
+						<div class="max-w-3xl mx-auto">
+							<ChatMessages scrollParent={chatScrollRef} />
+						</div>
+					</div>
+					
+					<div class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent">
+						<div class="max-w-3xl mx-auto">
+							<ChatForm placeholder="Ask anything" showModelSelector={true} />
+						</div>
+					</div>
+				</div>
+				
+				<!-- Right: Mini-Map Sidebar -->
+				{#if minimized}
+					<aside 
+						class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+						in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
+					>
+						<!-- Topology Section - clickable to go back to main view -->
+						<button 
+							class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
+							onclick={handleGoHome}
+							title="Click to return to main topology view"
+						>
+							<div class="flex items-center justify-between mb-3">
+								<div class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2">
+									<span class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"></span>
+								TOPOLOGY
+							</div>
+							<span class="text-xs text-white/70 tabular-nums">{nodeCount} {nodeCount === 1 ? 'NODE' : 'NODES'}</span>
+							</div>
+							
+							<div class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden">
+								
+								<TopologyGraph highlightedNodes={highlightedNodes()} />
+							</div>
+						</button>
+						
+						<!-- Instances Section (only shown when instances exist) -->
+						{#if instanceCount > 0}
+						<div class="p-4 flex-1">
+							<!-- Panel Header -->
+							<div class="flex items-center gap-2 mb-4">
+								<div class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"></div>
+								<h3 class="text-sm text-exo-yellow font-mono tracking-[0.2em] uppercase">Instances</h3>
+								<div class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"></div>
+							</div>
+								<div class="space-y-3 max-h-72 overflow-y-auto pr-1">
+									{#each Object.entries(instanceData) as [id, instance]}
+										{@const downloadInfo = getInstanceDownloadStatus(id, instance)}
+										{@const statusText = downloadInfo.statusText}
+										{@const isDownloading = downloadInfo.isDownloading}
+										{@const isFailed = statusText === 'FAILED'}
+										{@const isLoading = statusText === 'LOADING' || statusText === 'WARMING UP' || statusText === 'WAITING'}
+										{@const isReady = statusText === 'READY' || statusText === 'LOADED'}
+										{@const isRunning = statusText === 'RUNNING'}
+									<!-- Instance Card -->
+									{@const instanceModelId = getInstanceModelId(instance)}
+									{@const instanceInfo = getInstanceInfo(instance)}
+									{@const instanceConnections = getInstanceConnections(instance)}
+									<div 
+										class="relative group cursor-pointer"
+										role="button"
+										tabindex="0"
+										onmouseenter={() => hoveredInstanceId = id}
+										onmouseleave={() => hoveredInstanceId = null}
+										onclick={() => {
+											if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+												setSelectedChatModel(instanceModelId);
+											}
+										}}
+										onkeydown={(e) => {
+											if (e.key === 'Enter' || e.key === ' ') {
+												if (instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model') {
+													setSelectedChatModel(instanceModelId);
+												}
+											}
+										}}
+									>
+										<!-- Corner accents -->
+										<div class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+										<div class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+										<div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+										<div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading ? 'border-blue-500/50' : isFailed ? 'border-red-500/50' : isLoading ? 'border-yellow-500/50' : isReady ? 'border-green-500/50' : 'border-teal-500/50'}"></div>
+										
+										<div class="bg-exo-dark-gray/60 border border-l-2 {isDownloading ? 'border-blue-500/30 border-l-blue-400' : isFailed ? 'border-red-500/30 border-l-red-400' : isLoading ? 'border-exo-yellow/30 border-l-yellow-400' : isReady ? 'border-green-500/30 border-l-green-400' : 'border-teal-500/30 border-l-teal-400'} p-3">
+											
+											<div class="flex justify-between items-start mb-2 pl-2">
+												<div class="flex items-center gap-2">
+													<div class="w-1.5 h-1.5 {isDownloading ? 'bg-blue-400 animate-pulse' : isFailed ? 'bg-red-400' : isLoading ? 'bg-yellow-400 animate-pulse' : isReady ? 'bg-green-400' : 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"></div>
+													<span class="text-exo-light-gray font-mono text-xs tracking-wider">{id.slice(0, 8).toUpperCase()}</span>
+												</div>
+												<button 
+													onclick={() => deleteInstance(id)}
+													class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400/80 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
+												>
+													DELETE
+												</button>
+												</div>
+												<div class="pl-2">
+													<div class="text-exo-yellow text-sm font-mono tracking-wide truncate">{getInstanceModelId(instance)}</div>
+													<div class="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</span></div>
+														{#if instanceModelId && instanceModelId !== 'Unknown' && instanceModelId !== 'Unknown Model'}
+															<a
+																class="inline-flex items-center gap-1 text-[10px] text-white/60 hover:text-exo-yellow transition-colors mt-0.5"
+																href={`https://huggingface.co/${instanceModelId}`}
+																target="_blank"
+																rel="noreferrer noopener"
+																aria-label="View model on Hugging Face"
+															>
+																<span>Hugging Face</span>
+																<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+																	<path d="M14 3h7v7"/>
+																	<path d="M10 14l11-11"/>
+																	<path d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"/>
+																</svg>
+															</a>
+														{/if}
+													{#if instanceInfo.nodeNames.length > 0}
+														<div class="text-white/60 text-xs font-mono">{instanceInfo.nodeNames.join(', ')}</div>
+													{/if}
+													{#if debugEnabled && instanceConnections.length > 0}
+														<div class="mt-1 space-y-0.5">
+															{#each instanceConnections as conn}
+																<div class="text-[10px] leading-snug font-mono text-white/70">
+																	<span>{conn.from} -> {conn.to}: {conn.ip}</span>
+																	<span class="{conn.missingIface ? 'text-red-400' : 'text-white/60'}"> ({conn.ifaceLabel})</span>
+																</div>
+															{/each}
+														</div>
+													{/if}
+													
+													<!-- Download Progress -->
+													{#if downloadInfo.isDownloading && downloadInfo.progress}
+														<div class="mt-2 space-y-1">
+															<div class="flex justify-between text-sm font-mono">
+																<span class="text-blue-400">{downloadInfo.progress.percentage.toFixed(1)}%</span>
+																<span class="text-exo-light-gray">{formatBytes(downloadInfo.progress.downloadedBytes)}/{formatBytes(downloadInfo.progress.totalBytes)}</span>
+															</div>
+															<div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden">
+																<div 
+																	class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
+																	style="width: {downloadInfo.progress.percentage}%"
+																></div>
+															</div>
+															<div class="flex justify-between text-xs font-mono text-exo-light-gray">
+																<span>{formatSpeed(downloadInfo.progress.speed)}</span>
+																<span>ETA: {formatEta(downloadInfo.progress.etaMs)}</span>
+																<span>{downloadInfo.progress.completedFiles}/{downloadInfo.progress.totalFiles} files</span>
+															</div>
+														</div>
+														{#if downloadInfo.perNode.length > 0}
+															<div class="mt-2 space-y-1.5 max-h-48 overflow-y-auto pr-1">
+																{#each downloadInfo.perNode as nodeProg}
+																	<div class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2">
+																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray mb-1">
+																			<span class="text-white/80 truncate pr-2">{nodeProg.nodeName}</span>
+																			<span class="text-blue-300">{Math.min(100, Math.max(0, nodeProg.progress.percentage)).toFixed(1)}%</span>
+																		</div>
+																		<div class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mb-1.5">
+																			<div 
+																				class="absolute inset-y-0 left-0 bg-blue-500/80 transition-all duration-300"
+																				style="width: {Math.min(100, Math.max(0, nodeProg.progress.percentage)).toFixed(1)}%"
+																			></div>
+																		</div>
+																		<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray mb-1">
+																			<span>{formatBytes(nodeProg.progress.downloadedBytes)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
+																			<span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</span>
+																		</div>
+																	{#if nodeProg.progress.files.length > 0}
+																		{@const inProgressFiles = nodeProg.progress.files.filter(f => (f.percentage ?? 0) < 100)}
+																		{@const completedFiles = nodeProg.progress.files.filter(f => (f.percentage ?? 0) >= 100)}
+																		{#if inProgressFiles.length > 0}
+																			<div class="space-y-1">
+																				{#each inProgressFiles as f}
+																					<div class="text-[10px] font-mono text-exo-light-gray/80">
+																						<div class="flex items-center justify-between">
+																							<span class="truncate pr-2">{f.name}</span>
+																							<span class="text-white/70">{Math.min(100, Math.max(0, f.percentage)).toFixed(1)}%</span>
+																						</div>
+																						<div class="relative h-1 bg-exo-black/50 rounded-sm overflow-hidden mt-0.5">
+																							<div 
+																								class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow/70"
+																								style="width: {Math.min(100, Math.max(0, f.percentage)).toFixed(1)}%"
+																							></div>
+																						</div>
+																						<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5">
+																							<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+																							<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+																						</div>
+																					</div>
+																				{/each}
+																			</div>
+																		{/if}
+																		{#if completedFiles.length > 0}
+																			<div class="pt-1 space-y-0.5">
+																				{#each completedFiles as f}
+																					<div class="text-[10px] font-mono text-exo-light-gray/70 flex items-center justify-between">
+																						<span class="truncate pr-2">{f.name}</span>
+																						<span class="text-white/60">100%</span>
+																					</div>
+																				{/each}
+																			</div>
+																		{/if}
+																	{/if}
+																	</div>
+																{/each}
+															</div>
+														{/if}
+														<div class="text-sm text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
+													{:else}
+														<div class="text-sm {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
+													{/if}
+												</div>
+											</div>
+										</div>
+									{/each}
+								</div>
+						</div>
+						{/if}
+					</aside>
+				{/if}
+			</div>
+		{/if}
+	</main>
+
+</div>
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
new file mode 100644
index 00000000..81e29ed9
--- /dev/null
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -0,0 +1,441 @@
+<script lang="ts">
+	import { onMount } from 'svelte';
+	import {
+		topologyData,
+		downloads,
+		type DownloadProgress,
+		refreshState,
+		lastUpdate as lastUpdateStore
+	} from '$lib/stores/app.svelte';
+	import HeaderNav from '$lib/components/HeaderNav.svelte';
+
+	type FileProgress = {
+		name: string;
+		totalBytes: number;
+		downloadedBytes: number;
+		speed: number;
+		etaMs: number;
+		percentage: number;
+	};
+
+	type ModelEntry = {
+		modelId: string;
+		prettyName?: string | null;
+		percentage: number;
+		downloadedBytes: number;
+		totalBytes: number;
+		speed: number;
+		etaMs: number;
+		status: 'completed' | 'downloading';
+		files: FileProgress[];
+	};
+
+	type NodeEntry = {
+		nodeId: string;
+		nodeName: string;
+		models: ModelEntry[];
+	};
+
+	const data = $derived(topologyData());
+	const downloadsData = $derived(downloads());
+
+	function getNodeLabel(nodeId: string): string {
+		const node = data?.nodes?.[nodeId];
+		if (!node) return nodeId.slice(0, 8);
+		return node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8);
+	}
+
+	function getBytes(value: unknown): number {
+		if (typeof value === 'number') return value;
+		if (value && typeof value === 'object') {
+			const v = value as Record<string, unknown>;
+			if (typeof v.in_bytes === 'number') return v.in_bytes;
+			if (typeof v.inBytes === 'number') return v.inBytes;
+		}
+		return 0;
+	}
+
+	function formatBytes(bytes: number): string {
+		if (!bytes || bytes <= 0) return '0B';
+		const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+		const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
+		const val = bytes / Math.pow(1024, i);
+		return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+	}
+
+	function formatEta(ms: number): string {
+		if (!ms || ms <= 0) return '--';
+		const totalSeconds = Math.round(ms / 1000);
+		const s = totalSeconds % 60;
+		const m = Math.floor(totalSeconds / 60) % 60;
+		const h = Math.floor(totalSeconds / 3600);
+		if (h > 0) return `${h}h ${m}m`;
+		if (m > 0) return `${m}m ${s}s`;
+		return `${s}s`;
+	}
+
+	function formatSpeed(bytesPerSecond: number): string {
+		if (!bytesPerSecond || bytesPerSecond <= 0) return '--';
+		const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
+		const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(1024)), units.length - 1);
+		const val = bytesPerSecond / Math.pow(1024, i);
+		return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+	}
+
+	function clampPercent(value: number | undefined): number {
+		if (!Number.isFinite(value)) return 0;
+		return Math.min(100, Math.max(0, value as number));
+	}
+
+	function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
+		const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+		if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+
+		const shardObj = shardMetadata as Record<string, unknown>;
+		const shardKeys = Object.keys(shardObj);
+		if (shardKeys.length !== 1) return null;
+
+		const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+		if (!shardData) return null;
+
+		const modelMeta = shardData.model_meta ?? shardData.modelMeta;
+		if (!modelMeta || typeof modelMeta !== 'object') return null;
+
+		const meta = modelMeta as Record<string, unknown>;
+		return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
+	}
+
+	function parseDownloadProgress(payload: Record<string, unknown>): DownloadProgress | null {
+		const progress = payload.download_progress ?? payload.downloadProgress;
+		if (!progress || typeof progress !== 'object') return null;
+
+		const prog = progress as Record<string, unknown>;
+		const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
+		const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
+		const speed = (prog.speed as number) ?? 0;
+		const completedFiles = (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
+		const totalFiles = (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
+		const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+
+		const files: DownloadProgress['files'] = [];
+		const filesObj = (prog.files ?? {}) as Record<string, unknown>;
+		for (const [fileName, fileData] of Object.entries(filesObj)) {
+			if (!fileData || typeof fileData !== 'object') continue;
+			const fd = fileData as Record<string, unknown>;
+			const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+			const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+			files.push({
+				name: fileName,
+				totalBytes: fTotal,
+				downloadedBytes: fDownloaded,
+				speed: (fd.speed as number) ?? 0,
+				etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+				percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0
+			});
+		}
+
+		return {
+			totalBytes,
+			downloadedBytes,
+			speed,
+			etaMs: etaMs || (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
+			percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
+			completedFiles,
+			totalFiles,
+			files
+		};
+	}
+
+	function getBarGradient(percentage: number): string {
+		if (percentage >= 100) return 'from-green-500 to-green-400';
+		if (percentage <= 0) return 'from-red-500 to-red-400';
+		return 'from-exo-yellow to-exo-yellow/70';
+	}
+
+	let downloadOverview = $state<NodeEntry[]>([]);
+
+	$effect(() => {
+		try {
+			if (!downloadsData || Object.keys(downloadsData).length === 0) {
+				downloadOverview = [];
+				return;
+			}
+
+			const entries = Object.entries(downloadsData);
+			const built: NodeEntry[] = [];
+
+			for (const [nodeId, nodeDownloads] of entries) {
+				const modelMap = new Map<string, ModelEntry>();
+				const nodeEntries = Array.isArray(nodeDownloads)
+					? nodeDownloads
+					: nodeDownloads && typeof nodeDownloads === 'object'
+						? Object.values(nodeDownloads as Record<string, unknown>)
+						: [];
+
+				for (const downloadWrapped of nodeEntries) {
+					if (!downloadWrapped || typeof downloadWrapped !== 'object') continue;
+
+					const keys = Object.keys(downloadWrapped as Record<string, unknown>);
+					if (keys.length !== 1) continue;
+
+					const downloadKind = keys[0];
+					const downloadPayload = (downloadWrapped as Record<string, unknown>)[downloadKind] as Record<string, unknown>;
+					if (!downloadPayload) continue;
+
+					const modelId = extractModelIdFromDownload(downloadPayload) ?? 'unknown-model';
+					const prettyName = (() => {
+						const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+						if (!shardMetadata || typeof shardMetadata !== 'object') return null;
+						const shardObj = shardMetadata as Record<string, unknown>;
+						const shardKeys = Object.keys(shardObj);
+						if (shardKeys.length !== 1) return null;
+						const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
+						const modelMeta = shardData?.model_meta ?? shardData?.modelMeta;
+						if (!modelMeta || typeof modelMeta !== 'object') return null;
+						const meta = modelMeta as Record<string, unknown>;
+						return (meta.prettyName as string) ?? null;
+					})();
+
+					const rawProgress = (downloadPayload as Record<string, unknown>).download_progress
+						?? (downloadPayload as Record<string, unknown>).downloadProgress
+						?? {};
+					const totalBytes = getBytes((rawProgress as Record<string, unknown>).total_bytes ?? (rawProgress as Record<string, unknown>).totalBytes);
+					const downloadedBytes = getBytes((rawProgress as Record<string, unknown>).downloaded_bytes ?? (rawProgress as Record<string, unknown>).downloadedBytes);
+					const speed = (rawProgress as Record<string, unknown>).speed as number ?? 0;
+					const etaMs = (rawProgress as Record<string, unknown>).eta_ms as number ?? (rawProgress as Record<string, unknown>).etaMs as number ?? 0;
+					const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
+
+					const files: FileProgress[] = [];
+					const filesObj = (rawProgress as Record<string, unknown>).files as Record<string, unknown> | undefined;
+					if (filesObj && typeof filesObj === 'object') {
+						for (const [fileName, fileData] of Object.entries(filesObj)) {
+							if (!fileData || typeof fileData !== 'object') continue;
+							const fd = fileData as Record<string, unknown>;
+							const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
+							const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+							files.push({
+								name: fileName,
+								totalBytes: fTotal,
+								downloadedBytes: fDownloaded,
+								speed: (fd.speed as number) ?? 0,
+								etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
+								percentage: clampPercent(fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0)
+							});
+						}
+					}
+
+					const entry: ModelEntry = {
+						modelId,
+						prettyName,
+						percentage: downloadKind === 'DownloadCompleted' ? 100 : clampPercent(percentage),
+						downloadedBytes,
+						totalBytes,
+						speed,
+						etaMs,
+						status: downloadKind === 'DownloadCompleted' ? 'completed' : 'downloading',
+						files
+					};
+
+					const existing = modelMap.get(modelId);
+					if (!existing) {
+						modelMap.set(modelId, entry);
+					} else if (
+						(entry.status === 'completed' && existing.status !== 'completed') ||
+						(entry.status === existing.status && entry.downloadedBytes > existing.downloadedBytes)
+					) {
+						modelMap.set(modelId, entry);
+					}
+				}
+
+				let models = Array.from(modelMap.values()).sort((a, b) => b.percentage - a.percentage);
+				if (models.length === 0 && nodeEntries.length > 0) {
+					models = [{
+						modelId: 'Unknown download',
+						percentage: 0,
+						downloadedBytes: 0,
+						totalBytes: 0,
+						speed: 0,
+						etaMs: 0,
+						status: 'downloading',
+						files: []
+					}];
+				}
+
+				built.push({
+					nodeId,
+					nodeName: getNodeLabel(nodeId),
+					models
+				});
+			}
+
+			downloadOverview = built;
+		} catch (err) {
+			console.error('Parse downloads error', err);
+			downloadOverview = [];
+		}
+	});
+
+	const hasDownloads = $derived(downloadOverview.length > 0);
+	const lastUpdateTs = $derived(lastUpdateStore());
+	const downloadKeys = $derived(Object.keys(downloadsData || {}));
+
+	let expanded = $state<Set<string>>(new Set());
+	function toggleExpand(key: string): void {
+		const next = new Set(expanded);
+		if (next.has(key)) next.delete(key);
+		else next.add(key);
+		expanded = next;
+	}
+
+	onMount(() => {
+		// Ensure we fetch at least once when visiting downloads directly
+		refreshState();
+	});
+</script>
+
+<div class="min-h-screen bg-exo-dark-gray text-white">
+	<HeaderNav showHome={true} />
+	<div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
+		<div class="flex items-center justify-between gap-4 flex-wrap">
+			<div>
+				<h1 class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow">Downloads</h1>
+				<p class="text-sm text-exo-light-gray">Overview of models on each node</p>
+			</div>
+			<div class="flex items-center gap-3">
+				<button
+					type="button"
+					class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
+					onclick={() => refreshState()}
+					title="Force refresh from /state"
+				>
+					Refresh
+				</button>
+				<div class="text-[11px] font-mono text-exo-light-gray">
+					Last update: {lastUpdateTs ? new Date(lastUpdateTs).toLocaleTimeString() : 'n/a'}
+				</div>
+			</div>
+		</div>
+
+		{#if !hasDownloads}
+			<div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2">
+				<div class="text-sm">No downloads found. Start a model download to see progress here.</div>
+				<div class="text-[11px] text-exo-light-gray/70">
+					Download keys detected: {downloadKeys.length === 0 ? 'none' : downloadKeys.join(', ')}
+				</div>
+			</div>
+		{:else}
+			<div class="downloads-grid gap-4">
+				{#each downloadOverview as node}
+					<div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col">
+						<div class="flex items-center justify-between gap-3">
+							<div class="min-w-0 flex-1">
+								<div class="text-lg font-mono text-white truncate">{node.nodeName}</div>
+								<div class="text-xs text-exo-light-gray font-mono truncate">{node.nodeId}</div>
+							</div>
+							<div class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0">
+								<span class="text-green-400">{node.models.filter(m => m.status === 'completed').length}</span><span class="text-exo-yellow"> /{node.models.length} models</span>
+							</div>
+						</div>
+
+						{#each node.models as model}
+							{@const key = `${node.nodeId}|${model.modelId}`}
+							{@const pct = clampPercent(model.percentage)}
+							{@const gradient = getBarGradient(pct)}
+							{@const isExpanded = expanded.has(key)}
+							<div class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2">
+								<div class="flex items-center justify-between gap-3">
+									<div class="min-w-0 space-y-0.5">
+										<div class="text-sm font-mono text-white truncate">{model.prettyName ?? model.modelId}</div>
+										<div class="text-[11px] text-exo-light-gray font-mono truncate">
+											{model.modelId}
+										</div>
+										<div class="text-[11px] text-exo-light-gray font-mono">
+											{formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)}
+										</div>
+									</div>
+									<div class="flex items-center gap-2">
+										<span class="text-xs font-mono {pct >= 100 ? 'text-green-400' : pct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">
+											{pct.toFixed(1)}%
+										</span>
+										<button
+											type="button"
+											class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+											onclick={() => toggleExpand(key)}
+											aria-expanded={isExpanded}
+											title="Toggle file details"
+										>
+											<svg class="w-4 h-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
+												<path d="M6 8l4 4 4-4" class={isExpanded ? 'transform rotate-180 origin-center transition-transform duration-150' : 'transition-transform duration-150'}></path>
+											</svg>
+										</button>
+									</div>
+								</div>
+
+								<div class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden">
+									<div
+										class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
+										style={`width: ${pct.toFixed(1)}%`}
+									></div>
+								</div>
+
+								<div class="flex items-center justify-between text-xs font-mono text-exo-light-gray">
+									<span>{model.status === 'completed' ? 'Completed' : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span>
+									{#if model.status !== 'completed'}
+										<span>{model.files.length} file{model.files.length === 1 ? '' : 's'}</span>
+									{/if}
+								</div>
+
+								{#if isExpanded}
+									<div class="mt-2 space-y-1.5">
+										{#if model.files.length === 0}
+											<div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
+										{:else}
+											{#each model.files as f}
+												{@const fpct = clampPercent(f.percentage)}
+												{@const fgradient = getBarGradient(fpct)}
+												<div class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1">
+													<div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90">
+														<span class="truncate pr-2">{f.name}</span>
+														<span class="{fpct >= 100 ? 'text-green-400' : fpct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">{fpct.toFixed(1)}%</span>
+													</div>
+													<div class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden">
+														<div
+															class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
+															style={`width: ${fpct.toFixed(1)}%`}
+														></div>
+													</div>
+													<div class="flex items-center justify-between text-[10px] text-exo-light-gray/70">
+														<span>{formatBytes(f.downloadedBytes)} / {formatBytes(f.totalBytes)}</span>
+														<span>{formatSpeed(f.speed)} • ETA {formatEta(f.etaMs)}</span>
+													</div>
+												</div>
+											{/each}
+										{/if}
+									</div>
+								{/if}
+							</div>
+						{/each}
+					</div>
+				{/each}
+			</div>
+		{/if}
+
+	</div>
+</div>
+
+<style>
+	.downloads-grid {
+		display: grid;
+		grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+	}
+	@media (min-width: 1024px) {
+		.downloads-grid {
+			grid-template-columns: repeat(3, minmax(0, 1fr));
+		}
+	}
+	@media (min-width: 1440px) {
+		.downloads-grid {
+			grid-template-columns: repeat(4, minmax(0, 1fr));
+		}
+	}
+</style>
diff --git a/dashboard/static/exo-logo.png b/dashboard/static/exo-logo.png
new file mode 100644
index 00000000..199bcfdd
Binary files /dev/null and b/dashboard/static/exo-logo.png differ
diff --git a/dashboard/static/favicon.ico b/dashboard/static/favicon.ico
new file mode 100644
index 00000000..c0ae2099
Binary files /dev/null and b/dashboard/static/favicon.ico differ
diff --git a/dashboard/svelte.config.js b/dashboard/svelte.config.js
new file mode 100644
index 00000000..991b07b0
--- /dev/null
+++ b/dashboard/svelte.config.js
@@ -0,0 +1,28 @@
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+	preprocess: [vitePreprocess()],
+
+	kit: {
+		paths: {
+			relative: true
+		},
+		router: { type: 'hash' },
+		adapter: adapter({
+			pages: 'build',
+			assets: 'build',
+			fallback: 'index.html',
+			precompress: false,
+			strict: true
+		}),
+		alias: {
+			$lib: 'src/lib',
+			$components: 'src/lib/components'
+		}
+	}
+};
+
+export default config;
+
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 00000000..51db996c
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,15 @@
+{
+	"extends": "./.svelte-kit/tsconfig.json",
+	"compilerOptions": {
+		"allowJs": true,
+		"checkJs": true,
+		"esModuleInterop": true,
+		"forceConsistentCasingInFileNames": true,
+		"resolveJsonModule": true,
+		"skipLibCheck": true,
+		"sourceMap": true,
+		"strict": true,
+		"moduleResolution": "bundler"
+	}
+}
+
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
new file mode 100644
index 00000000..4d22f688
--- /dev/null
+++ b/dashboard/vite.config.ts
@@ -0,0 +1,16 @@
+import tailwindcss from '@tailwindcss/vite';
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+	plugins: [tailwindcss(), sveltekit()],
+	server: {
+		proxy: {
+			'/v1': 'http://localhost:8000',
+			'/state': 'http://localhost:8000',
+			'/models': 'http://localhost:8000',
+			'/instance': 'http://localhost:8000'
+		}
+	}
+});
+
diff --git a/flake.nix b/flake.nix
index c6141754..9d3ade75 100644
--- a/flake.nix
+++ b/flake.nix
@@ -81,6 +81,9 @@
               # NIX
               nixpkgs-fmt
 
+              # SVELTE
+              nodejs
+
               # MISC
               just
               jq
@@ -96,7 +99,6 @@
 
           shellHook = ''
             # PYTHON
-            export DASHBOARD_DIR="$(git rev-parse --show-toplevel)/dashboard"
             export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.python313}/lib"
             echo
             echo "🍎🍎 Run 'just <recipe>' to get started"
diff --git a/justfile b/justfile
index 6f4e67e9..0a82d616 100644
--- a/justfile
+++ b/justfile
@@ -20,7 +20,19 @@ rust-rebuild:
     cargo run --bin stub_gen
     just sync-clean
 
+build-dashboard:
+    #!/usr/bin/env bash
+    cd dashboard
+    npm install
+    npm run build
+
+package:
+    uv run pyinstaller packaging/pyinstaller/exo.spec
+
 clean:
     rm -rf **/__pycache__
     rm -rf target/
     rm -rf .venv
+    rm -rf dashboard/node_modules
+    rm -rf dashboard/.svelte-kit
+    rm -rf dashboard/build
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 172ae5c1..ffbf3fde 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -1,4 +1,3 @@
-import os
 import time
 from collections.abc import AsyncGenerator
 from typing import cast
@@ -15,6 +14,7 @@ from hypercorn.config import Config
 from hypercorn.typing import ASGIFramework
 from loguru import logger
 
+from exo.master.placement import place_instance as get_instance_placements
 from exo.shared.apply import apply
 from exo.shared.election import ElectionMessage
 from exo.shared.logging import InterceptLogger
@@ -23,11 +23,14 @@ from exo.shared.models.model_meta import get_model_meta
 from exo.shared.types.api import (
     ChatCompletionMessage,
     ChatCompletionResponse,
+    CreateInstanceParams,
     CreateInstanceResponse,
-    CreateInstanceTaskParams,
     DeleteInstanceResponse,
     ModelList,
     ModelListModel,
+    PlaceInstanceParams,
+    PlacementPreview,
+    PlacementPreviewResponse,
     StreamingChoiceResponse,
 )
 from exo.shared.types.chunks import TokenChunk
@@ -37,17 +40,20 @@ from exo.shared.types.commands import (
     CreateInstance,
     DeleteInstance,
     ForwarderCommand,
+    PlaceInstance,
     TaskFinished,
 )
 from exo.shared.types.common import CommandId, NodeId, SessionId
 from exo.shared.types.events import ChunkGenerated, Event, ForwarderEvent, IndexedEvent
 from exo.shared.types.memory import Memory
-from exo.shared.types.models import ModelMetadata
+from exo.shared.types.models import ModelId, ModelMetadata
 from exo.shared.types.state import State
 from exo.shared.types.tasks import ChatCompletionTaskParams
-from exo.shared.types.worker.instances import Instance, InstanceId
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
+from exo.shared.types.worker.shards import Sharding
 from exo.utils.banner import print_startup_banner
 from exo.utils.channels import Receiver, Sender, channel
+from exo.utils.dashboard_path import find_dashboard
 from exo.utils.event_buffer import OrderedBuffer
 
 HIDE_THINKING = False
@@ -91,7 +97,8 @@ class API:
         # This lets us pause the API if an election is running
         election_receiver: Receiver[ElectionMessage],
     ) -> None:
-        self._state = State()
+        self.state = State()
+        self._event_log: list[Event] = []
         self.command_sender = command_sender
         self.global_event_receiver = global_event_receiver
         self.election_receiver = election_receiver
@@ -111,12 +118,7 @@ class API:
         self.app.mount(
             "/",
             StaticFiles(
-                directory=os.environ.get(
-                    "DASHBOARD_DIR",
-                    os.path.abspath(
-                        os.path.join(os.path.dirname(__file__), "../../../dashboard")
-                    ),
-                ),
+                directory=find_dashboard(),
                 html=True,
             ),
             name="dashboard",
@@ -127,7 +129,7 @@ class API:
 
     def reset(self, new_session_id: SessionId, result_clock: int):
         logger.info("Resetting API State")
-        self._state = State()
+        self.state = State()
         self.session_id = new_session_id
         self.event_buffer = OrderedBuffer[Event]()
         self._chat_completion_queues = {}
@@ -150,51 +152,194 @@ class API:
         )
 
     def _setup_routes(self) -> None:
+        self.app.get("/node_id")(lambda: self.node_id)
         self.app.post("/instance")(self.create_instance)
+        self.app.post("/place_instance")(self.place_instance)
+        self.app.get("/instance/placement")(self.get_placement)
+        self.app.get("/instance/previews")(self.get_placement_previews)
         self.app.get("/instance/{instance_id}")(self.get_instance)
         self.app.delete("/instance/{instance_id}")(self.delete_instance)
         self.app.get("/models")(self.get_models)
         self.app.get("/v1/models")(self.get_models)
         self.app.post("/v1/chat/completions")(self.chat_completions)
-        self.app.get("/state")(self.state)
-
-    async def state(self) -> State:
-        return self._state
-
-    async def create_instance(
-        self, payload: CreateInstanceTaskParams
-    ) -> CreateInstanceResponse:
-        model_meta = await resolve_model_meta(payload.model_id)
-        required_memory = model_meta.storage_size
-        available_memory = self._calculate_total_available_memory()
+        self.app.get("/state")(lambda: self.state)
+        self.app.get("/events")(lambda: self._event_log)
 
-        if required_memory > available_memory:
-            raise HTTPException(
-                status_code=400,
-                detail=f"Insufficient memory to create instance. Required: {required_memory.in_gb:.1f}GB, Available: {available_memory.in_gb:.1f}GB",
-            )
-
-        command = CreateInstance(
-            model_meta=model_meta,
+    async def place_instance(self, payload: PlaceInstanceParams):
+        command = PlaceInstance(
+            model_meta=await resolve_model_meta(payload.model_id),
+            sharding=payload.sharding,
             instance_meta=payload.instance_meta,
             min_nodes=payload.min_nodes,
-            sharding=payload.sharding,
         )
         await self._send(command)
 
         return CreateInstanceResponse(
             message="Command received.",
             command_id=command.command_id,
-            model_meta=model_meta,
         )
 
+    async def create_instance(
+        self, payload: CreateInstanceParams
+    ) -> CreateInstanceResponse:
+        command = CreateInstance(instance=payload.instance)
+        await self._send(command)
+
+        return CreateInstanceResponse(
+            message="Command received.",
+            command_id=command.command_id,
+        )
+
+    async def get_placement(
+        self,
+        model_id: str,
+        sharding: Sharding = Sharding.Pipeline,
+        instance_meta: InstanceMeta = InstanceMeta.MlxRing,
+        min_nodes: int = 1,
+    ) -> Instance:
+        model_meta = await resolve_model_meta(model_id)
+
+        try:
+            placements = get_instance_placements(
+                PlaceInstance(
+                    model_meta=model_meta,
+                    sharding=sharding,
+                    instance_meta=instance_meta,
+                    min_nodes=min_nodes,
+                ),
+                topology=self.state.topology,
+                current_instances=self.state.instances,
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+        current_ids = set(self.state.instances.keys())
+        new_ids = [
+            instance_id for instance_id in placements if instance_id not in current_ids
+        ]
+        if len(new_ids) != 1:
+            raise HTTPException(
+                status_code=500,
+                detail="Expected exactly one new instance from placement",
+            )
+
+        return placements[new_ids[0]]
+
+    async def get_placement_previews(
+        self, model_id: ModelId
+    ) -> PlacementPreviewResponse:
+        seen: set[tuple[ModelId, Sharding, InstanceMeta, int]] = set()
+        previews: list[PlacementPreview] = []
+        if len(list(self.state.topology.list_nodes())) == 0:
+            return PlacementPreviewResponse(previews=[])
+
+        cards = [card for card in MODEL_CARDS.values() if card.short_id == model_id]
+        if not cards:
+            raise HTTPException(status_code=404, detail=f"Model {model_id} not found")
+
+        instance_combinations: list[tuple[Sharding, InstanceMeta, int]] = []
+        for sharding in (Sharding.Pipeline, Sharding.Tensor):
+            for instance_meta in (InstanceMeta.MlxRing, InstanceMeta.MlxJaccl):
+                instance_combinations.extend(
+                    [
+                        (sharding, instance_meta, i)
+                        for i in range(
+                            1, len(list(self.state.topology.list_nodes())) + 1
+                        )
+                    ]
+                )
+        # TODO: PDD
+        # instance_combinations.append((Sharding.PrefillDecodeDisaggregation, InstanceMeta.MlxRing, 1))
+
+        for card in cards:
+            model_meta = card.metadata
+            for sharding, instance_meta, min_nodes in instance_combinations:
+                try:
+                    placements = get_instance_placements(
+                        PlaceInstance(
+                            model_meta=model_meta,
+                            sharding=sharding,
+                            instance_meta=instance_meta,
+                            min_nodes=min_nodes,
+                        ),
+                        topology=self.state.topology,
+                        current_instances=self.state.instances,
+                    )
+                except ValueError as exc:
+                    if (card.model_id, sharding, instance_meta, 0) not in seen:
+                        previews.append(
+                            PlacementPreview(
+                                model_id=card.model_id,
+                                sharding=sharding,
+                                instance_meta=instance_meta,
+                                instance=None,
+                                error=str(exc),
+                            )
+                        )
+                    seen.add((card.model_id, sharding, instance_meta, 0))
+                    continue
+
+                current_ids = set(self.state.instances.keys())
+                new_instances = [
+                    instance
+                    for instance_id, instance in placements.items()
+                    if instance_id not in current_ids
+                ]
+
+                if len(new_instances) != 1:
+                    if (card.model_id, sharding, instance_meta, 0) not in seen:
+                        previews.append(
+                            PlacementPreview(
+                                model_id=card.model_id,
+                                sharding=sharding,
+                                instance_meta=instance_meta,
+                                instance=None,
+                                error="Expected exactly one new instance from placement",
+                            )
+                        )
+                    seen.add((card.model_id, sharding, instance_meta, 0))
+                    continue
+
+                instance = new_instances[0]
+                shard_assignments = instance.shard_assignments
+                node_ids = list(shard_assignments.node_to_runner.keys())
+
+                memory_delta_by_node: dict[str, int] = {}
+                if node_ids:
+                    total_bytes = model_meta.storage_size.in_bytes
+                    per_node = total_bytes // len(node_ids)
+                    remainder = total_bytes % len(node_ids)
+                    for index, node_id in enumerate(sorted(node_ids, key=str)):
+                        extra = 1 if index < remainder else 0
+                        memory_delta_by_node[str(node_id)] = per_node + extra
+
+                if (
+                    card.model_id,
+                    sharding,
+                    instance_meta,
+                    len(node_ids),
+                ) not in seen:
+                    previews.append(
+                        PlacementPreview(
+                            model_id=card.model_id,
+                            sharding=sharding,
+                            instance_meta=instance_meta,
+                            instance=instance,
+                            memory_delta_by_node=memory_delta_by_node or None,
+                            error=None,
+                        )
+                    )
+                seen.add((card.model_id, sharding, instance_meta, len(node_ids)))
+
+        return PlacementPreviewResponse(previews=previews)
+
     def get_instance(self, instance_id: InstanceId) -> Instance:
-        if instance_id not in self._state.instances:
+        if instance_id not in self.state.instances:
             raise HTTPException(status_code=404, detail="Instance not found")
-        return self._state.instances[instance_id]
+        return self.state.instances[instance_id]
 
     async def delete_instance(self, instance_id: InstanceId) -> DeleteInstanceResponse:
-        if instance_id not in self._state.instances:
+        if instance_id not in self.state.instances:
             raise HTTPException(status_code=404, detail="Instance not found")
 
         command = DeleteInstance(
@@ -261,7 +406,7 @@ class API:
 
         if not any(
             instance.shard_assignments.model_id == payload.model
-            for instance in self._state.instances.values()
+            for instance in self.state.instances.values()
         ):
             await self._trigger_notify_user_to_download_model(payload.model)
             raise HTTPException(
@@ -281,7 +426,7 @@ class API:
         """Calculate total available memory across all nodes in bytes."""
         total_available = Memory()
 
-        for node in self._state.topology.list_nodes():
+        for node in self.state.topology.list_nodes():
             if node.node_profile is not None:
                 total_available += node.node_profile.memory.ram_available
 
@@ -313,7 +458,7 @@ class API:
         async with create_task_group() as tg:
             self._tg = tg
             logger.info("Starting API")
-            tg.start_soon(self._apply_state)
+            tg.start_soon(self._applystate)
             tg.start_soon(self._pause_on_new_election)
             print_startup_banner(self.port)
             await serve(
@@ -325,14 +470,15 @@ class API:
         self.command_sender.close()
         self.global_event_receiver.close()
 
-    async def _apply_state(self):
+    async def _applystate(self):
         with self.global_event_receiver as events:
             async for f_event in events:
                 if f_event.origin != self.session_id.master_node_id:
                     continue
                 self.event_buffer.ingest(f_event.origin_idx, f_event.event)
                 for idx, event in self.event_buffer.drain_indexed():
-                    self._state = apply(self._state, IndexedEvent(event=event, idx=idx))
+                    self._event_log.append(event)
+                    self.state = apply(self.state, IndexedEvent(event=event, idx=idx))
                     if (
                         isinstance(event, ChunkGenerated)
                         and event.command_id in self._chat_completion_queues
diff --git a/src/exo/master/main.py b/src/exo/master/main.py
index 149bfbd2..55b72d7d 100644
--- a/src/exo/master/main.py
+++ b/src/exo/master/main.py
@@ -5,9 +5,10 @@ from anyio.abc import TaskGroup
 from loguru import logger
 
 from exo.master.placement import (
-    get_instance_placements_after_create,
-    get_instance_placements_after_delete,
+    add_instance_to_placements,
+    delete_instance,
     get_transition_events,
+    place_instance,
 )
 from exo.shared.apply import apply
 from exo.shared.types.commands import (
@@ -15,6 +16,7 @@ from exo.shared.types.commands import (
     CreateInstance,
     DeleteInstance,
     ForwarderCommand,
+    PlaceInstance,
     RequestEventLog,
     TaskFinished,
     TestCommand,
@@ -148,19 +150,26 @@ class Master:
 
                             self.command_task_mapping[command.command_id] = task_id
                         case DeleteInstance():
-                            placement = get_instance_placements_after_delete(
-                                command, self.state.instances
+                            placement = delete_instance(command, self.state.instances)
+                            transition_events = get_transition_events(
+                                self.state.instances, placement
+                            )
+                            generated_events.extend(transition_events)
+                        case PlaceInstance():
+                            placement = place_instance(
+                                command,
+                                self.state.topology,
+                                self.state.instances,
                             )
                             transition_events = get_transition_events(
                                 self.state.instances, placement
                             )
                             generated_events.extend(transition_events)
                         case CreateInstance():
-                            placement = get_instance_placements_after_create(
+                            placement = add_instance_to_placements(
                                 command,
                                 self.state.topology,
                                 self.state.instances,
-                                tb_only=self.tb_only,
                             )
                             transition_events = get_transition_events(
                                 self.state.instances, placement
diff --git a/src/exo/master/placement.py b/src/exo/master/placement.py
index c0862c10..f3856f93 100644
--- a/src/exo/master/placement.py
+++ b/src/exo/master/placement.py
@@ -17,6 +17,7 @@ from exo.shared.topology import Topology
 from exo.shared.types.commands import (
     CreateInstance,
     DeleteInstance,
+    PlaceInstance,
 )
 from exo.shared.types.common import Host
 from exo.shared.types.events import Event, InstanceCreated, InstanceDeleted
@@ -35,12 +36,20 @@ def random_ephemeral_port() -> int:
     return random.randint(49152, 65535)
 
 
-def get_instance_placements_after_create(
+def add_instance_to_placements(
     command: CreateInstance,
     topology: Topology,
     current_instances: Mapping[InstanceId, Instance],
-    *,
-    tb_only: bool = False,
+) -> Mapping[InstanceId, Instance]:
+    # TODO: validate against topology
+
+    return {**current_instances, command.instance.instance_id: command.instance}
+
+
+def place_instance(
+    command: PlaceInstance,
+    topology: Topology,
+    current_instances: Mapping[InstanceId, Instance],
 ) -> dict[InstanceId, Instance]:
     all_nodes = list(topology.list_nodes())
 
@@ -64,9 +73,7 @@ def get_instance_placements_after_create(
         if topology.get_subgraph_from_nodes(cycle).is_thunderbolt_cycle(cycle)
     ]
 
-    if tb_only and smallest_tb_cycles == []:
-        raise ValueError("No TB cycles found with sufficient memory")
-    elif smallest_tb_cycles != []:
+    if smallest_tb_cycles != []:
         smallest_cycles = smallest_tb_cycles
 
     cycles_with_leaf_nodes: list[list[NodeInfo]] = [
@@ -138,7 +145,7 @@ def get_instance_placements_after_create(
     return target_instances
 
 
-def get_instance_placements_after_delete(
+def delete_instance(
     command: DeleteInstance,
     current_instances: Mapping[InstanceId, Instance],
 ) -> dict[InstanceId, Instance]:
diff --git a/src/exo/master/tests/test_master.py b/src/exo/master/tests/test_master.py
index 948bcb1f..c2111baf 100644
--- a/src/exo/master/tests/test_master.py
+++ b/src/exo/master/tests/test_master.py
@@ -11,8 +11,8 @@ from exo.shared.types.api import ChatCompletionMessage, ChatCompletionTaskParams
 from exo.shared.types.commands import (
     ChatCompletion,
     CommandId,
-    CreateInstance,
     ForwarderCommand,
+    PlaceInstance,
 )
 from exo.shared.types.common import NodeId, SessionId
 from exo.shared.types.events import (
@@ -117,7 +117,7 @@ async def test_master():
             ForwarderCommand(
                 origin=node_id,
                 command=(
-                    CreateInstance(
+                    PlaceInstance(
                         command_id=CommandId(),
                         model_meta=ModelMetadata(
                             model_id=ModelId("llama-3.2-1b"),
diff --git a/src/exo/master/tests/test_placement.py b/src/exo/master/tests/test_placement.py
index 1bfdf4e2..c688e8ff 100644
--- a/src/exo/master/tests/test_placement.py
+++ b/src/exo/master/tests/test_placement.py
@@ -4,11 +4,11 @@ import pytest
 from loguru import logger
 
 from exo.master.placement import (
-    get_instance_placements_after_create,
     get_transition_events,
+    place_instance,
 )
 from exo.shared.topology import Topology
-from exo.shared.types.commands import CreateInstance
+from exo.shared.types.commands import PlaceInstance
 from exo.shared.types.common import CommandId, NodeId
 from exo.shared.types.events import InstanceCreated, InstanceDeleted
 from exo.shared.types.memory import Memory
@@ -52,8 +52,8 @@ def model_meta() -> ModelMetadata:
     )
 
 
-def create_instance_command(model_meta: ModelMetadata) -> CreateInstance:
-    return CreateInstance(
+def place_instance_command(model_meta: ModelMetadata) -> PlaceInstance:
+    return PlaceInstance(
         command_id=CommandId(),
         model_meta=model_meta,
         sharding=Sharding.Pipeline,
@@ -85,7 +85,7 @@ def test_get_instance_placements_create_instance(
         available_memory
     )  # make it exactly fit across all nodes
 
-    cic = create_instance_command(model_meta)
+    cic = place_instance_command(model_meta)
     node_id_a = NodeId()
     node_id_b = NodeId()
     node_id_c = NodeId()
@@ -97,7 +97,7 @@ def test_get_instance_placements_create_instance(
     topology.add_connection(create_connection(node_id_c, node_id_a))
 
     # act
-    placements = get_instance_placements_after_create(cic, topology, {})
+    placements = place_instance(cic, topology, {})
 
     # assert
     assert len(placements) == 1
@@ -129,7 +129,7 @@ def test_get_instance_placements_one_node_exact_fit(
     topology = Topology()
     node_id = NodeId()
     topology.add_node(create_node(1000 * 1024, node_id))
-    cic = create_instance_command(
+    cic = place_instance_command(
         ModelMetadata(
             model_id=ModelId("test-model"),
             storage_size=Memory.from_kb(1000),
@@ -137,7 +137,7 @@ def test_get_instance_placements_one_node_exact_fit(
             n_layers=10,
         ),
     )
-    placements = get_instance_placements_after_create(cic, topology, {})
+    placements = place_instance(cic, topology, {})
 
     assert len(placements) == 1
     instance_id = list(placements.keys())[0]
@@ -154,7 +154,7 @@ def test_get_instance_placements_one_node_fits_with_extra_memory(
     topology = Topology()
     node_id = NodeId()
     topology.add_node(create_node(1001 * 1024, node_id))
-    cic = create_instance_command(
+    cic = place_instance_command(
         ModelMetadata(
             model_id=ModelId("test-model"),
             storage_size=Memory.from_kb(1000),
@@ -162,7 +162,7 @@ def test_get_instance_placements_one_node_fits_with_extra_memory(
             n_layers=10,
         ),
     )
-    placements = get_instance_placements_after_create(cic, topology, {})
+    placements = place_instance(cic, topology, {})
 
     assert len(placements) == 1
     instance_id = list(placements.keys())[0]
@@ -179,7 +179,7 @@ def test_get_instance_placements_one_node_not_fit(
     topology = Topology()
     node_id = NodeId()
     topology.add_node(create_node(1000 * 1024, node_id))
-    cic = create_instance_command(
+    cic = place_instance_command(
         model_meta=ModelMetadata(
             model_id=ModelId("test-model"),
             storage_size=Memory.from_kb(1001),
@@ -189,7 +189,7 @@ def test_get_instance_placements_one_node_not_fit(
     )
 
     with pytest.raises(ValueError, match="No cycles found with sufficient memory"):
-        get_instance_placements_after_create(cic, topology, {})
+        place_instance(cic, topology, {})
 
 
 def test_get_transition_events_no_change(instance: Instance):
@@ -292,12 +292,12 @@ def test_placement_prioritizes_leaf_cycle_with_less_memory(
     topology.add_connection(create_connection(node_id_e, node_id_y))
     topology.add_connection(create_connection(node_id_f, node_id_z))
 
-    cic = create_instance_command(
+    cic = place_instance_command(
         model_meta=model_meta,
     )
 
     # Act
-    placements = get_instance_placements_after_create(cic, topology, {})
+    placements = place_instance(cic, topology, {})
 
     # Assert the chosen cycle is A-B-C (contains at least one leaf node), even though
     # D-E-F has more total memory.
@@ -420,7 +420,7 @@ def test_tensor_rdma_backend_connectivity_matrix(
     topology.add_connection(conn_c_b)
     topology.add_connection(conn_a_c)
 
-    cic = CreateInstance(
+    cic = PlaceInstance(
         sharding=Sharding.Tensor,
         instance_meta=InstanceMeta.MlxJaccl,
         command_id=CommandId(),
@@ -428,7 +428,7 @@ def test_tensor_rdma_backend_connectivity_matrix(
         min_nodes=1,
     )
 
-    placements = get_instance_placements_after_create(cic, topology, {})
+    placements = place_instance(cic, topology, {})
 
     assert len(placements) == 1
     instance_id = list(placements.keys())[0]
diff --git a/src/exo/shared/models/model_cards.py b/src/exo/shared/models/model_cards.py
index 6368a72d..17f00e4c 100644
--- a/src/exo/shared/models/model_cards.py
+++ b/src/exo/shared/models/model_cards.py
@@ -5,7 +5,7 @@ from exo.utils.pydantic_ext import CamelCaseModel
 
 class ModelCard(CamelCaseModel):
     short_id: str
-    model_id: str
+    model_id: ModelId
     name: str
     description: str
     tags: list[str]
@@ -40,35 +40,63 @@ MODEL_CARDS: dict[str, ModelCard] = {
     #         n_layers=61,
     #     ),
     # ),
-    "deepseek-v3.1": ModelCard(
-        short_id="deepseek-v3.1",
-        model_id="mlx-community/DeepSeek-V3.1-8bit",
-        name="DeepSeek V3.1 (8-bit)",
+    "deepseek-v3.1-4bit": ModelCard(
+        short_id="deepseek-v3.1-4bit",
+        model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
+        name="DeepSeek V3.1 (4-bit)",
         description="""DeepSeek V3.1 is a large language model trained on the DeepSeek V3.1 dataset.""",
         tags=[],
         metadata=ModelMetadata(
-            model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
-            pretty_name="DeepSeek V3.1 (8-bit)",
-            storage_size=Memory.from_kb(754706307),
+            model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
+            pretty_name="DeepSeek V3.1 (4-bit)",
+            storage_size=Memory.from_gb(378),
             n_layers=61,
         ),
     ),
-    "deepseek-v3.1:4bit": ModelCard(
-        short_id="deepseek-v3.1:4bit",
-        model_id="mlx-community/DeepSeek-V3.1-4bit",
-        name="DeepSeek V3.1 (4-bit)",
+    "deepseek-v3.1-8bit": ModelCard(
+        short_id="deepseek-v3.1-8bit",
+        model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
+        name="DeepSeek V3.1 (8-bit)",
         description="""DeepSeek V3.1 is a large language model trained on the DeepSeek V3.1 dataset.""",
         tags=[],
         metadata=ModelMetadata(
-            model_id=ModelId("mlx-community/DeepSeek-V3.1-4bit"),
-            pretty_name="DeepSeek V3.1 (4-bit)",
-            storage_size=Memory.from_kb(754706307 // 2),  # TODO !!!!!
+            model_id=ModelId("mlx-community/DeepSeek-V3.1-8bit"),
+            pretty_name="DeepSeek V3.1 (8-bit)",
+            storage_size=Memory.from_gb(713),
             n_layers=61,
         ),
     ),
+    # "deepseek-v3.2": ModelCard(
+    #     short_id="deepseek-v3.2",
+    #     model_id=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
+    #     name="DeepSeek V3.2 (8-bit)",
+    #     description="""DeepSeek V3.2 is a large language model trained on the DeepSeek V3.2 dataset.""",
+    #     tags=[],
+    #     metadata=ModelMetadata(
+    #         model_id=ModelId("mlx-community/DeepSeek-V3.2-8bit"),
+    #         pretty_name="DeepSeek V3.2 (8-bit)",
+    #         storage_size=Memory.from_kb(754706307),
+    #         n_layers=61,
+    #         hidden_size=7168,
+    #     ),
+    # ),
+    # "deepseek-v3.2-4bit": ModelCard(
+    #     short_id="deepseek-v3.2-4bit",
+    #     model_id=ModelId("mlx-community/DeepSeek-V3.2-4bit"),
+    #     name="DeepSeek V3.2 (4-bit)",
+    #     description="""DeepSeek V3.2 is a large language model trained on the DeepSeek V3.2 dataset.""",
+    #     tags=[],
+    #     metadata=ModelMetadata(
+    #         model_id=ModelId("mlx-community/DeepSeek-V3.2-4bit"),
+    #         pretty_name="DeepSeek V3.2 (4-bit)",
+    #         storage_size=Memory.from_kb(754706307 // 2),  # TODO !!!!!
+    #         n_layers=61,
+    #         hidden_size=7168,
+    #     ),
+    # ),
     # deepseek r1
-    # "deepseek-r1-0528:4bit": ModelCard(
-    #     short_id="deepseek-r1-0528:4bit",
+    # "deepseek-r1-0528-4bit": ModelCard(
+    #     short_id="deepseek-r1-0528-4bit",
     #     model_id="mlx-community/DeepSeek-R1-0528-4bit",
     #     name="DeepSeek-R1-0528 (4-bit)",
     #     description="""DeepSeek R1 is a large language model trained on the DeepSeek R1 dataset.""",
@@ -78,6 +106,7 @@ MODEL_CARDS: dict[str, ModelCard] = {
     #         pretty_name="DeepSeek R1 671B (4-bit)",
     #         storage_size=Memory.from_kb(409706307),
     #         n_layers=61,
+    #         hidden_size=7168,
     #     ),
     # ),
     # "deepseek-r1-0528": ModelCard(
@@ -91,226 +120,279 @@ MODEL_CARDS: dict[str, ModelCard] = {
     #         pretty_name="DeepSeek R1 671B (8-bit)",
     #         storage_size=Memory.from_bytes(754998771712),
     #         n_layers=61,
+    # .       hidden_size=7168,
     #     ),
     # ),
     # kimi k2
     "kimi-k2-instruct-4bit": ModelCard(
         short_id="kimi-k2-instruct-4bit",
-        model_id="mlx-community/Kimi-K2-Instruct-4bit",
+        model_id=ModelId("mlx-community/Kimi-K2-Instruct-4bit"),
         name="Kimi K2 Instruct (4-bit)",
         description="""Kimi K2 is a large language model trained on the Kimi K2 dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Kimi-K2-Instruct-4bit"),
             pretty_name="Kimi K2 Instruct (4-bit)",
-            storage_size=Memory.from_bytes(577597603840),
+            storage_size=Memory.from_gb(578),
             n_layers=61,
         ),
     ),
     "kimi-k2-thinking": ModelCard(
         short_id="kimi-k2-thinking",
-        model_id="mlx-community/Kimi-K2-Thinking",
-        name="Kimi K2 Thinking",
+        model_id=ModelId("mlx-community/Kimi-K2-Thinking"),
+        name="Kimi K2 Thinking (4-bit)",
         description="""Kimi K2 Thinking is the latest, most capable version of open-source thinking model.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Kimi-K2-Thinking"),
-            pretty_name="Kimi K2 Thinking",
-            storage_size=Memory.from_bytes(577597603840),
+            pretty_name="Kimi K2 Thinking (4-bit)",
+            storage_size=Memory.from_gb(658),
             n_layers=61,
         ),
     ),
     # llama-3.1
     "llama-3.1-8b": ModelCard(
         short_id="llama-3.1-8b",
-        model_id="mlx-community/Meta-Llama-3.1-8B-Instruct-4bit",
-        name="Llama 3.1 8B",
+        model_id=ModelId("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"),
+        name="Llama 3.1 8B (4-bit)",
         description="""Llama 3.1 is a large language model trained on the Llama 3.1 dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit"),
-            pretty_name="Llama 3.1 8B",
-            storage_size=Memory.from_kb(4411528),
+            pretty_name="Llama 3.1 8B (4-bit)",
+            storage_size=Memory.from_mb(4423),
             n_layers=32,
         ),
     ),
     "llama-3.1-70b": ModelCard(
         short_id="llama-3.1-70b",
-        model_id="mlx-community/Meta-Llama-3.1-70B-Instruct-4bit",
-        name="Llama 3.1 70B",
+        model_id=ModelId("mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"),
+        name="Llama 3.1 70B (4-bit)",
         description="""Llama 3.1 is a large language model trained on the Llama 3.1 dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Meta-Llama-3.1-70B-Instruct-4bit"),
-            pretty_name="Llama 3.1 70B",
-            storage_size=Memory.from_kb(38758160),
+            pretty_name="Llama 3.1 70B (4-bit)",
+            storage_size=Memory.from_mb(38769),
             n_layers=80,
         ),
     ),
     # llama-3.2
     "llama-3.2-1b": ModelCard(
         short_id="llama-3.2-1b",
-        model_id="mlx-community/Llama-3.2-1B-Instruct-4bit",
-        name="Llama 3.2 1B",
+        model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"),
+        name="Llama 3.2 1B (4-bit)",
         description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Llama-3.2-1B-Instruct-4bit"),
-            pretty_name="Llama 3.2 1B",
-            storage_size=Memory.from_kb(678948),
+            pretty_name="Llama 3.2 1B (4-bit)",
+            storage_size=Memory.from_mb(696),
             n_layers=16,
         ),
     ),
     "llama-3.2-3b": ModelCard(
         short_id="llama-3.2-3b",
-        model_id="mlx-community/Llama-3.2-3B-Instruct-4bit",
-        name="Llama 3.2 3B",
+        model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-4bit"),
+        name="Llama 3.2 3B (4-bit)",
         description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-4bit"),
-            pretty_name="Llama 3.2 3B",
-            storage_size=Memory.from_kb(1765062),
+            pretty_name="Llama 3.2 3B (4-bit)",
+            storage_size=Memory.from_mb(1777),
+            n_layers=28,
+        ),
+    ),
+    "llama-3.2-3b-8bit": ModelCard(
+        short_id="llama-3.2-3b-8bit",
+        model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-8bit"),
+        name="Llama 3.2 3B (8-bit)",
+        description="""Llama 3.2 is a large language model trained on the Llama 3.2 dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Llama-3.2-3B-Instruct-8bit"),
+            pretty_name="Llama 3.2 3B (8-bit)",
+            storage_size=Memory.from_mb(3339),
             n_layers=28,
         ),
     ),
     # llama-3.3
     "llama-3.3-70b": ModelCard(
         short_id="llama-3.3-70b",
-        model_id="mlx-community/Llama-3.3-70B-Instruct-4bit",
+        model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-4bit"),
         name="Llama 3.3 70B (4-bit)",
         description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-4bit"),
             pretty_name="Llama 3.3 70B",
-            storage_size=Memory.from_kb(38758160),
+            storage_size=Memory.from_mb(38769),
             n_layers=80,
         ),
     ),
     "llama-3.3-70b-8bit": ModelCard(
         short_id="llama-3.3-70b-8bit",
-        model_id="mlx-community/Llama-3.3-70B-Instruct-8bit",
+        model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-8bit"),
         name="Llama 3.3 70B (8-bit)",
         description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Llama-3.3-70B-Instruct-8bit"),
             pretty_name="Llama 3.3 70B (8-bit)",
-            storage_size=Memory.from_kb(77516320),
+            storage_size=Memory.from_mb(73242),
             n_layers=80,
         ),
     ),
     "llama-3.3-70b-fp16": ModelCard(
         short_id="llama-3.3-70b-fp16",
-        model_id="mlx-community/llama-3.3-70b-instruct-fp16",
+        model_id=ModelId("mlx-community/llama-3.3-70b-instruct-fp16"),
         name="Llama 3.3 70B (FP16)",
         description="""The Meta Llama 3.3 multilingual large language model (LLM) is an instruction tuned generative model in 70B (text in/text out)""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/llama-3.3-70b-instruct-fp16"),
             pretty_name="Llama 3.3 70B (FP16)",
-            storage_size=Memory.from_kb(155032640),
+            storage_size=Memory.from_mb(137695),
             n_layers=80,
         ),
     ),
     # phi-3
     "phi-3-mini": ModelCard(
         short_id="phi-3-mini",
-        model_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
-        name="Phi 3 Mini 128k",
+        model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
+        name="Phi 3 Mini 128k (4-bit)",
         description="""Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
-            pretty_name="Phi 3 Mini 128k",
-            storage_size=Memory.from_kb(2099262),
+            pretty_name="Phi 3 Mini 128k (4-bit)",
+            storage_size=Memory.from_mb(2099),
             n_layers=32,
         ),
     ),
-    # "phi-3-mini:128k": ModelCard(
-    #     short_id="phi-3-mini:128k",
-    #     model_id="mlx-community/Phi-3-mini-128k-instruct-4bit",
-    #     name="Phi 3 Mini 128k",
-    #     description="""Phi 3 Mini is a large language model trained on the Phi 3 Mini dataset.""",
-    #     tags=[],
-    #     metadata=ModelMetadata(
-    #         model_id=ModelId("mlx-community/Phi-3-mini-128k-instruct-4bit"),
-    #         pretty_name="Phi 3 Mini 128k",
-    #         storage_size=Memory.from_kb(2099262),
-    #         n_layers=32,
-    #     ),
-    # ),
     # qwen3
     "qwen3-0.6b": ModelCard(
         short_id="qwen3-0.6b",
-        model_id="mlx-community/Qwen3-0.6B-4bit",
-        name="Qwen3 0.6B",
+        model_id=ModelId("mlx-community/Qwen3-0.6B-4bit"),
+        name="Qwen3 0.6B (4-bit)",
         description="""Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Qwen3-0.6B-4bit"),
-            pretty_name="Qwen3 0.6B",
-            storage_size=Memory.from_kb(327512),
+            pretty_name="Qwen3 0.6B (4-bit)",
+            storage_size=Memory.from_mb(327),
+            n_layers=28,
+        ),
+    ),
+    "qwen3-0.6b-8bit": ModelCard(
+        short_id="qwen3-0.6b-8bit",
+        model_id=ModelId("mlx-community/Qwen3-0.6B-8bit"),
+        name="Qwen3 0.6B (8-bit)",
+        description="""Qwen3 0.6B is a large language model trained on the Qwen3 0.6B dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Qwen3-0.6B-8bit"),
+            pretty_name="Qwen3 0.6B (8-bit)",
+            storage_size=Memory.from_mb(666),
             n_layers=28,
         ),
     ),
     "qwen3-30b": ModelCard(
         short_id="qwen3-30b",
-        model_id="mlx-community/Qwen3-30B-A3B-4bit",
-        name="Qwen3 30B (Active 3B)",
+        model_id=ModelId("mlx-community/Qwen3-30B-A3B-4bit"),
+        name="Qwen3 30B A3B (4-bit)",
         description="""Qwen3 30B is a large language model trained on the Qwen3 30B dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Qwen3-30B-A3B-4bit"),
-            pretty_name="Qwen3 30B (Active 3B)",
-            storage_size=Memory.from_kb(16772092),
+            pretty_name="Qwen3 30B A3B (4-bit)",
+            storage_size=Memory.from_mb(16797),
             n_layers=48,
         ),
     ),
-    # "qwen3-235b-a22b": ModelCard(
-    #     short_id="qwen3-235b-a22b",
-    #     model_id="mlx-community/Qwen3-235B-A22B-4bit",
-    #     name="Qwen3 235B, Active 22B (4-bit)",
-    #     description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
-    #     tags=[],
-    #     metadata=ModelMetadata(
-    #         model_id=ModelId("mlx-community/Qwen3-235B-A22B-4bit"),
-    #         pretty_name="Qwen3 235B, Active 22B (4-bit)",
-    #         storage_size=Memory.from_kb(123207680),
-    #         n_layers=94,
-    #     ),
-    # ),
+    "qwen3-30b-8bit": ModelCard(
+        short_id="qwen3-30b-8bit",
+        model_id=ModelId("mlx-community/Qwen3-30B-A3B-8bit"),
+        name="Qwen3 30B A3B (8-bit)",
+        description="""Qwen3 30B is a large language model trained on the Qwen3 30B dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Qwen3-30B-A3B-8bit"),
+            pretty_name="Qwen3 30B A3B (8-bit)",
+            storage_size=Memory.from_mb(31738),
+            n_layers=48,
+        ),
+    ),
+    "qwen3-235b-a22b-4bit": ModelCard(
+        short_id="qwen3-235b-a22b-4bit",
+        model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-4bit"),
+        name="Qwen3 235B A22B (4-bit)",
+        description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-4bit"),
+            pretty_name="Qwen3 235B A22B (4-bit)",
+            storage_size=Memory.from_gb(132),
+            n_layers=94,
+        ),
+    ),
     "qwen3-235b-a22b-8bit": ModelCard(
         short_id="qwen3-235b-a22b-8bit",
-        model_id="mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit",
-        name="Qwen3 235B, Active 22B (8-bit)",
+        model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit"),
+        name="Qwen3 235B A22B (8-bit)",
         description="""Qwen3 235B (Active 22B) is a large language model trained on the Qwen3 235B dataset.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/Qwen3-235B-A22B-Instruct-2507-8bit"),
-            pretty_name="Qwen3 235B, Active 22B (8-bit)",
-            storage_size=Memory.from_kb(246415360),
+            pretty_name="Qwen3 235B A22B (8-bit)",
+            storage_size=Memory.from_gb(250),
             n_layers=94,
         ),
     ),
+    "qwen3-coder-480b-a35b-4bit": ModelCard(
+        short_id="qwen3-coder-480b-a35b-4bit",
+        model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit"),
+        name="Qwen3 Coder 480B A35B (4-bit)",
+        description="""Qwen3 Coder 480B (Active 35B) is a large language model trained on the Qwen3 Coder 480B dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-4bit"),
+            pretty_name="Qwen3 Coder 480B A35B (4-bit)",
+            storage_size=Memory.from_gb(270),
+            n_layers=62,
+        ),
+    ),
+    "qwen3-coder-480b-a35b-8bit": ModelCard(
+        short_id="qwen3-coder-480b-a35b-8bit",
+        model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-8bit"),
+        name="Qwen3 Coder 480B A35B (8-bit)",
+        description="""Qwen3 Coder 480B (Active 35B) is a large language model trained on the Qwen3 Coder 480B dataset.""",
+        tags=[],
+        metadata=ModelMetadata(
+            model_id=ModelId("mlx-community/Qwen3-Coder-480B-A35B-Instruct-8bit"),
+            pretty_name="Qwen3 Coder 480B A35B (8-bit)",
+            storage_size=Memory.from_gb(540),
+            n_layers=62,
+        ),
+    ),
     # granite
     "granite-3.3-2b": ModelCard(
         short_id="granite-3.3-2b",
-        model_id="mlx-community/granite-3.3-2b-instruct-fp16",
-        name="Granite 3.3 2B",
+        model_id=ModelId("mlx-community/granite-3.3-2b-instruct-fp16"),
+        name="Granite 3.3 2B (FP16)",
         description="""Granite-3.3-2B-Instruct is a 2-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.""",
         tags=[],
         metadata=ModelMetadata(
             model_id=ModelId("mlx-community/granite-3.3-2b-instruct-fp16"),
-            pretty_name="Granite 3.3 2B",
-            storage_size=Memory.from_kb(4948320),
+            pretty_name="Granite 3.3 2B (FP16)",
+            storage_size=Memory.from_mb(4951),
             n_layers=40,
         ),
     ),
     # "granite-3.3-8b": ModelCard(
     #     short_id="granite-3.3-8b",
-    #     model_id="mlx-community/granite-3.3-8b-instruct-fp16",
+    #     model_id=ModelId("mlx-community/granite-3.3-8b-instruct-fp16"),
     #     name="Granite 3.3 8B",
     #     description="""Granite-3.3-8B-Instruct is a 8-billion parameter 128K context length language model fine-tuned for improved reasoning and instruction-following capabilities.""",
     #     tags=[],
@@ -335,4 +417,35 @@ MODEL_CARDS: dict[str, ModelCard] = {
     #         n_layers=30,
     #     ),
     # ),
+    # gpt-oss
+    # "gpt-oss-120b-MXFP4-Q8": ModelCard(
+    #     short_id="gpt-oss-120b-MXFP4-Q8",
+    #     model_id=ModelId("mlx-community/gpt-oss-120b-MXFP4-Q8"),
+    #     name="GPT-OSS 120B (MXFP4-Q8, MLX)",
+    #     description="""OpenAI's GPT-OSS 120B is a 117B-parameter Mixture-of-Experts model designed for high-reasoning and general-purpose use; this variant is a 4-bit MLX conversion for Apple Silicon.""",
+    #     tags=[],
+    #     metadata=ModelMetadata(
+    #         model_id=ModelId("mlx-community/gpt-oss-120b-MXFP4-Q8"),
+    #         pretty_name="GPT-OSS 120B (MXFP4-Q8, MLX)",
+    #         storage_size=Memory.from_kb(68_996_301),
+    #         n_layers=36,
+    #         hidden_size=2880,
+    #         supports_tensor=True,
+    #     ),
+    # ),
+    # "gpt-oss-20b-4bit": ModelCard(
+    #     short_id="gpt-oss-20b-4bit",
+    #     model_id=ModelId("mlx-community/gpt-oss-20b-MXFP4-Q4"),
+    #     name="GPT-OSS 20B (MXFP4-Q4, MLX)",
+    #     description="""OpenAI's GPT-OSS 20B is a medium-sized MoE model for lower-latency and local or specialized use cases; this MLX variant uses MXFP4 4-bit quantization.""",
+    #     tags=[],
+    #     metadata=ModelMetadata(
+    #         model_id=ModelId("mlx-community/gpt-oss-20b-MXFP4-Q4"),
+    #         pretty_name="GPT-OSS 20B (MXFP4-Q4, MLX)",
+    #         storage_size=Memory.from_kb(11_744_051),
+    #         n_layers=24,
+    #         hidden_size=2880,
+    #         supports_tensor=True,
+    #     ),
+    # ),
 }
diff --git a/src/exo/shared/types/api.py b/src/exo/shared/types/api.py
index 56def4dc..30b01e3e 100644
--- a/src/exo/shared/types/api.py
+++ b/src/exo/shared/types/api.py
@@ -1,11 +1,12 @@
 import time
 from typing import Any, Literal
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
+from pydantic_core import PydanticUseDefault
 
 from exo.shared.types.common import CommandId
-from exo.shared.types.models import ModelMetadata
-from exo.shared.types.worker.instances import InstanceId, InstanceMeta
+from exo.shared.types.models import ModelId
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
 from exo.shared.types.worker.shards import Sharding
 
 FinishReason = Literal[
@@ -24,6 +25,8 @@ class ModelListModel(BaseModel):
     description: str = Field(default="")
     context_length: int = Field(default=0)
     tags: list[str] = Field(default=[])
+    storage_size_megabytes: int = Field(default=0)
+    supports_tensor: bool = Field(default=False)
 
 
 class ModelList(BaseModel):
@@ -132,13 +135,37 @@ class ChatCompletionTaskParams(BaseModel):
     user: str | None = None
 
 
-class CreateInstanceTaskParams(BaseModel):
-    # TODO: in future the user could specify a specific Instance, not just a model_id
+class PlaceInstanceParams(BaseModel):
     model_id: str
     sharding: Sharding = Sharding.Pipeline
     instance_meta: InstanceMeta = InstanceMeta.MlxRing
     min_nodes: int = 1
 
+    @field_validator("sharding", "instance_meta", mode="plain")
+    @classmethod
+    def use_default(cls, v: object):
+        if not v or not isinstance(v, (Sharding, InstanceMeta)):
+            raise PydanticUseDefault()
+        return v
+
+
+class CreateInstanceParams(BaseModel):
+    instance: Instance
+
+
+class PlacementPreview(BaseModel):
+    model_id: ModelId
+    sharding: Sharding
+    instance_meta: InstanceMeta
+    instance: Instance | None = None
+    # Keys are NodeId strings, values are additional bytes that would be used on that node
+    memory_delta_by_node: dict[str, int] | None = None
+    error: str | None = None
+
+
+class PlacementPreviewResponse(BaseModel):
+    previews: list[PlacementPreview]
+
 
 class DeleteInstanceTaskParams(BaseModel):
     instance_id: str
@@ -147,7 +174,6 @@ class DeleteInstanceTaskParams(BaseModel):
 class CreateInstanceResponse(BaseModel):
     message: str
     command_id: CommandId
-    model_meta: ModelMetadata
 
 
 class DeleteInstanceResponse(BaseModel):
diff --git a/src/exo/shared/types/commands.py b/src/exo/shared/types/commands.py
index 0a584ff5..5d8a5026 100644
--- a/src/exo/shared/types/commands.py
+++ b/src/exo/shared/types/commands.py
@@ -3,7 +3,7 @@ from pydantic import Field
 from exo.shared.types.api import ChatCompletionTaskParams
 from exo.shared.types.common import CommandId, NodeId
 from exo.shared.types.models import ModelMetadata
-from exo.shared.types.worker.instances import InstanceId, InstanceMeta
+from exo.shared.types.worker.instances import Instance, InstanceId, InstanceMeta
 from exo.shared.types.worker.shards import Sharding
 from exo.utils.pydantic_ext import CamelCaseModel, TaggedModel
 
@@ -20,13 +20,17 @@ class ChatCompletion(BaseCommand):
     request_params: ChatCompletionTaskParams
 
 
-class CreateInstance(BaseCommand):
+class PlaceInstance(BaseCommand):
     model_meta: ModelMetadata
     sharding: Sharding
     instance_meta: InstanceMeta
     min_nodes: int
 
 
+class CreateInstance(BaseCommand):
+    instance: Instance
+
+
 class DeleteInstance(BaseCommand):
     instance_id: InstanceId
 
@@ -43,6 +47,7 @@ Command = (
     TestCommand
     | RequestEventLog
     | ChatCompletion
+    | PlaceInstance
     | CreateInstance
     | DeleteInstance
     | TaskFinished
diff --git a/src/exo/shared/types/memory.py b/src/exo/shared/types/memory.py
index 562c3c87..b97fb345 100644
--- a/src/exo/shared/types/memory.py
+++ b/src/exo/shared/types/memory.py
@@ -47,6 +47,11 @@ class Memory(CamelCaseModel):
         """Construct a new Memory object from a number of megabytes"""
         return cls(in_bytes=round(val * (1024**2)))
 
+    @classmethod
+    def from_gb(cls, val: float) -> Self:
+        """Construct a new Memory object from a number of megabytes"""
+        return cls(in_bytes=round(val * (1024**3)))
+
     @property
     def in_gb(self) -> float:
         """The approximate gigabytes this memory represents."""
diff --git a/src/exo/utils/dashboard_path.py b/src/exo/utils/dashboard_path.py
new file mode 100644
index 00000000..b9e6990c
--- /dev/null
+++ b/src/exo/utils/dashboard_path.py
@@ -0,0 +1,45 @@
+import os
+import sys
+from pathlib import Path
+from typing import cast
+
+
+def find_dashboard() -> Path:
+    dashboard = (
+        _find_dashboard_in_env()
+        or _find_dashboard_in_repo()
+        or _find_dashboard_in_bundle()
+    )
+    if not dashboard:
+        raise FileNotFoundError(
+            "Unable to locate dashboard assets. Export DASHBOARD_DIR or rebuild the binary."
+        )
+    return dashboard
+
+
+def _find_dashboard_in_env() -> Path | None:
+    env = os.environ.get("DASHBOARD_DIR")
+    if not env:
+        return None
+    resolved_env = Path(env).expanduser().resolve()
+
+    return resolved_env
+
+
+def _find_dashboard_in_repo() -> Path | None:
+    current_module = Path(__file__).resolve()
+    for parent in current_module.parents:
+        build = parent / "dashboard" / "build"
+        if build.is_dir() and (build / "index.html").exists():
+            return build
+    return None
+
+
+def _find_dashboard_in_bundle() -> Path | None:
+    frozen_root = cast(str | None, getattr(sys, "_MEIPASS", None))
+    if frozen_root is None:
+        return None
+    candidate = Path(frozen_root) / "dashboard"
+    if candidate.is_dir():
+        return candidate
+    return None

← 880a18d2 fix disconnects  ·  back to Exo  ·  prep repo for v1 0fcee708 →