diff --git a/unity-client/Assets/Scripts/UI/PreconController.cs b/unity-client/Assets/Scripts/UI/PreconController.cs
new file mode 100644
index 0000000..cd4d75b
--- /dev/null
+++ b/unity-client/Assets/Scripts/UI/PreconController.cs
@@ -0,0 +1,403 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.SceneManagement;
+using TMPro;
+using Newtonsoft.Json;
+using CommanderAILab.Models;
+using CommanderAILab.Services;
+
+namespace CommanderAILab.UI
+{
+ ///
+ /// Precon browser scene — browse, search, and install preconstructed Commander decks.
+ /// Grid display with card images, deck details panel, install/install-all buttons,
+ /// and refresh from GitHub functionality.
+ ///
+ public class PreconController : MonoBehaviour
+ {
+ // -- Header ——————————————————————————————————
+ [Header("Navigation")]
+ [SerializeField] private Button backButton;
+
+ [Header("Search & Filter")]
+ [SerializeField] private TMP_InputField searchInput;
+ [SerializeField] private TMP_Dropdown colorFilterDropdown;
+ [SerializeField] private Button searchButton;
+ [SerializeField] private Button clearButton;
+
+ [Header("Deck Grid")]
+ [SerializeField] private Transform deckGridParent;
+ [SerializeField] private GameObject deckCardPrefab;
+ [SerializeField] private ScrollRect gridScrollRect;
+
+ [Header("Deck Detail Panel")]
+ [SerializeField] private GameObject detailPanel;
+ [SerializeField] private Image detailCommanderImage;
+ [SerializeField] private TMP_Text detailDeckName;
+ [SerializeField] private TMP_Text detailColorIdentity;
+ [SerializeField] private TMP_Text detailCardCount;
+ [SerializeField] private TMP_Text detailThemeText;
+ [SerializeField] private Button detailInstallButton;
+ [SerializeField] private Button detailCloseButton;
+
+ [Header("Batch Actions")]
+ [SerializeField] private Button installAllButton;
+ [SerializeField] private Button refreshButton;
+ [SerializeField] private TMP_Text statusText;
+
+ [Header("Loading & Error")]
+ [SerializeField] private GameObject loadingSpinner;
+ [SerializeField] private GameObject errorPanel;
+ [SerializeField] private TMP_Text errorText;
+ [SerializeField] private Button retryButton;
+
+ // -- State ——————————————————————————————————
+ private List _allPrecons = new();
+ private List _filteredPrecons = new();
+ private PreconDeck _selectedPrecon;
+ private bool _isLoading = false;
+ private HashSet _installedDecks = new();
+
+ // -- Serializable response wrappers ———————————————————
+ [Serializable]
+ private class PreconListResponse
+ {
+ public List precons;
+ }
+
+ [Serializable]
+ public class PreconDeck
+ {
+ public string fileName;
+ public string name;
+ public string commander;
+ public string colorIdentity;
+ public int cardCount;
+ public string theme;
+ public string imageUrl;
+ }
+
+ [Serializable]
+ private class InstallRequest
+ {
+ public string fileName;
+ }
+
+ [Serializable]
+ private class InstallResponse
+ {
+ public bool installed;
+ public string deckName;
+ public string destination;
+ public string message;
+ }
+
+ [Serializable]
+ private class BatchInstallRequest
+ {
+ public List fileNames;
+ }
+
+ [Serializable]
+ private class BatchInstallResponse
+ {
+ public List results;
+ }
+
+ [Serializable]
+ private class BatchInstallResult
+ {
+ public string fileName;
+ public bool installed;
+ public string deckName;
+ public string error;
+ }
+
+ [Serializable]
+ private class RefreshResponse
+ {
+ public string message;
+ public int total;
+ }
+
+ // ========================================================
+ // Lifecycle
+ // ========================================================
+
+ private void Start()
+ {
+ backButton.onClick.AddListener(OnBack);
+ searchButton.onClick.AddListener(OnSearch);
+ clearButton.onClick.AddListener(OnClear);
+ retryButton.onClick.AddListener(LoadPrecons);
+ installAllButton.onClick.AddListener(OnInstallAll);
+ refreshButton.onClick.AddListener(OnRefresh);
+ detailInstallButton.onClick.AddListener(OnInstallSelected);
+ detailCloseButton.onClick.AddListener(() => detailPanel.SetActive(false));
+
+ if (searchInput != null)
+ searchInput.onSubmit.AddListener(_ => OnSearch());
+
+ detailPanel.SetActive(false);
+ errorPanel.SetActive(false);
+
+ LoadPrecons();
+ }
+
+ // ========================================================
+ // Data Loading
+ // ========================================================
+
+ private void LoadPrecons()
+ {
+ if (_isLoading) return;
+ _isLoading = true;
+ loadingSpinner.SetActive(true);
+ errorPanel.SetActive(false);
+ statusText.text = "Loading precons...";
+
+ ApiClient.Instance.Get(
+ "/api/lab/precons",
+ OnPreconsLoaded,
+ OnError
+ );
+ }
+
+ private void OnPreconsLoaded(PreconListResponse response)
+ {
+ _isLoading = false;
+ loadingSpinner.SetActive(false);
+
+ _allPrecons = response.precons ?? new List();
+ statusText.text = $"{_allPrecons.Count} precon deck(s) available";
+
+ ApplyFilter();
+ }
+
+ // ========================================================
+ // Search & Filter
+ // ========================================================
+
+ private void OnSearch()
+ {
+ ApplyFilter();
+ }
+
+ private void OnClear()
+ {
+ searchInput.text = "";
+ if (colorFilterDropdown != null)
+ colorFilterDropdown.value = 0;
+ ApplyFilter();
+ }
+
+ private void ApplyFilter()
+ {
+ string query = searchInput.text.Trim().ToLowerInvariant();
+ string colorFilter = "";
+ if (colorFilterDropdown != null && colorFilterDropdown.value > 0)
+ colorFilter = colorFilterDropdown.options[colorFilterDropdown.value].text;
+
+ _filteredPrecons = _allPrecons.FindAll(p =>
+ {
+ bool matchesSearch = string.IsNullOrEmpty(query)
+ || (p.name != null && p.name.ToLowerInvariant().Contains(query))
+ || (p.commander != null && p.commander.ToLowerInvariant().Contains(query))
+ || (p.theme != null && p.theme.ToLowerInvariant().Contains(query));
+
+ bool matchesColor = string.IsNullOrEmpty(colorFilter)
+ || (p.colorIdentity != null && p.colorIdentity.Contains(colorFilter));
+
+ return matchesSearch && matchesColor;
+ });
+
+ RenderGrid();
+ }
+
+ // ========================================================
+ // Grid Rendering
+ // ========================================================
+
+ private void RenderGrid()
+ {
+ // Clear existing children
+ for (int i = deckGridParent.childCount - 1; i >= 0; i--)
+ Destroy(deckGridParent.GetChild(i).gameObject);
+
+ foreach (var precon in _filteredPrecons)
+ {
+ var card = Instantiate(deckCardPrefab, deckGridParent);
+ var texts = card.GetComponentsInChildren(true);
+ if (texts.Length > 0) texts[0].text = precon.name ?? precon.fileName;
+ if (texts.Length > 1) texts[1].text = precon.commander ?? "";
+ if (texts.Length > 2) texts[2].text = precon.colorIdentity ?? "";
+
+ // Installed badge
+ bool installed = _installedDecks.Contains(precon.fileName);
+ var badge = card.transform.Find("InstalledBadge");
+ if (badge != null) badge.gameObject.SetActive(installed);
+
+ // Click to show detail
+ var btn = card.GetComponent