--[[
	LearnBlox Studio v4 — Plugin Roblox Studio
	Interface alignée sur le design system du site (css/_variables.css) :
	mêmes couleurs de marque, mêmes rayons, même échelle typographique,
	mêmes couleurs de "track" (script / build / monetise).

	Principe anti-bug visuel :
	  - AutomaticSize.Y sur les conteneurs + TextLabel (le texte ne se coupe
	    plus jamais, ne se chevauche plus)
	  - UIListLayout partout (aucun positionnement absolu fragile)
	  - AutomaticCanvasSize.Y sur les ScrollingFrame (canvas auto)
	  => tout s'adapte au redimensionnement du widget.
]]

local HttpService = game:GetService("HttpService")
local ServerScriptService = game:GetService("ServerScriptService")
local TweenService = game:GetService("TweenService")
local RunService = game:GetService("RunService")
local ScriptEditorService = game:GetService("ScriptEditorService")
local LogService = game:GetService("LogService")
local Selection = game:GetService("Selection") -- contexte Studio pour Bloxi
-- Insertion du code proposé par Bloxi : encadrée par un waypoint, donc
-- annulable d'un Ctrl+Z comme n'importe quelle action Studio.
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")

-- Clés de stockage partagées entre DataModels (Edit <-> serveur de playtest)
local SETTING_SESSION = "LB_playtest_session" -- id de session (change à chaque playtest)
local SETTING_SEEN = "LB_playtest_seen"       -- dernière session consommée par l'UI Edit
local SETTING_CTX = "LB_last_exercise_ctx"    -- {moduleId, moduleTitle, exId, index}
local SETTING_RESULT = "LB_playtest_result"   -- {exId, success, alreadyCompleted, points, error}
local SETTING_PROJ_CTX = "LB_project_ctx"     -- {moduleId, moduleTitle} : projet en cours (validation au playtest)
local SETTING_PROJ_RESULT = "LB_project_result" -- {moduleId, done, total, newly}
-- Erreurs Lua du dernier playtest, écrites depuis le DataModel de test et
-- relues en édition : c'est ainsi que Bloxi les voit sans rien demander.
local SETTING_ERRORS = "LB_last_errors"       -- { "msg", … } (12 max)

-- ═══════════════════════════════ CONFIG ═══════════════════════════════
-- UN SEUL serveur : learnblox.fr. Les environnements beta/dev/local ont été
-- fermés — la bascule automatique entre plusieurs bases envoyait le plugin
-- sur un serveur mal configuré (ex. sans SUPABASE_SERVICE_ROLE_KEY, qui
-- répondait « connecté » mais sans le statut Premium) sans que rien ne
-- l'indique. Une seule cible = plus d'ambiguïté possible.
local API_BASE = "https://learnblox.fr/api/plugin"
local VERSION = "2.1.1"
local SETTING_PAIRED_USER = "LB_paired_user_id"
local SETTING_PAIRED_NAME = "LB_paired_username"

-- ═══════════════════════════════ THEME ════════════════════════════════
-- V4 : palette portée 1:1 depuis css/_variables.css du site, pour que le
-- plugin et le dashboard web soient visuellement la même application.
--   light : --primary-accent #3B6EF5, --bg-body #f8f9fa, --bg-card #ffffff
--   dark  : --primary-accent #60a5fa, --bg-body #0f172a, --bg-card #1e293b
-- Les couleurs de "track" (script / build / monetise) viennent aussi du site.
local THEMES = {
	dark = {
		bg        = Color3.fromRGB(15, 23, 42),    -- --bg-body   #0f172a
		bgSurface = Color3.fromRGB(23, 33, 56),    -- entre body et card
		card      = Color3.fromRGB(30, 41, 59),    -- --bg-card   #1e293b
		cardHover = Color3.fromRGB(44, 58, 79),    -- --bg-hover  #2c3a4f
		elevated  = Color3.fromRGB(51, 65, 85),    -- --bg-element #334155
		border    = Color3.fromRGB(51, 65, 85),    -- --border-color #334155
		borderSoft= Color3.fromRGB(41, 53, 72),

		accent      = Color3.fromRGB(96, 165, 250), -- #60a5fa
		accentDark  = Color3.fromRGB(59, 130, 246), -- #3b82f6
		accentBg    = Color3.fromRGB(30, 45, 72),   -- --primary-light aplati
		accentBright= Color3.fromRGB(96, 165, 250),
		onAccent    = Color3.fromRGB(255, 255, 255),-- --text-on-accent

		green     = Color3.fromRGB(16, 185, 129),   -- --color-success
		greenBg   = Color3.fromRGB(22, 54, 56),
		blue      = Color3.fromRGB(96, 165, 250),
		blueDark  = Color3.fromRGB(59, 130, 246),
		blueSoft  = Color3.fromRGB(30, 45, 72),
		purple    = Color3.fromRGB(167, 139, 250),  -- --track-build éclairci
		purpleBg  = Color3.fromRGB(46, 40, 76),
		orange    = Color3.fromRGB(245, 158, 11),   -- --color-warning
		orangeBg  = Color3.fromRGB(56, 44, 24),
		gold      = Color3.fromRGB(234, 179, 8),    -- --color-gold
		red       = Color3.fromRGB(239, 68, 68),    -- --color-error
		redBg     = Color3.fromRGB(60, 30, 34),

		text      = Color3.fromRGB(203, 213, 225),  -- --text-primary  #cbd5e1
		textSec   = Color3.fromRGB(148, 163, 184),  -- --text-secondary
		textMuted = Color3.fromRGB(100, 116, 139),  -- --text-muted
		textBright= Color3.fromRGB(248, 250, 252),  -- --text-headings

		white     = Color3.fromRGB(255, 255, 255),
		black     = Color3.fromRGB(15, 23, 42),
	},
	light = {
		bg        = Color3.fromRGB(248, 249, 250),  -- --bg-body   #f8f9fa
		bgSurface = Color3.fromRGB(255, 255, 255),  -- --bg-content
		card      = Color3.fromRGB(255, 255, 255),  -- --bg-card
		cardHover = Color3.fromRGB(243, 244, 246),  -- --bg-hover  #f3f4f6
		elevated  = Color3.fromRGB(239, 246, 255),  -- --bg-element #eff6ff
		border    = Color3.fromRGB(229, 231, 235),  -- --border-color #e5e7eb
		borderSoft= Color3.fromRGB(238, 240, 244),

		accent      = Color3.fromRGB(59, 110, 245), -- #3B6EF5
		accentDark  = Color3.fromRGB(37, 84, 214),  -- #2554d6
		accentBg    = Color3.fromRGB(235, 240, 254),
		accentBright= Color3.fromRGB(59, 110, 245),
		onAccent    = Color3.fromRGB(255, 255, 255),

		green     = Color3.fromRGB(16, 185, 129),
		greenBg   = Color3.fromRGB(230, 249, 242),
		blue      = Color3.fromRGB(59, 110, 245),
		blueDark  = Color3.fromRGB(37, 84, 214),
		blueSoft  = Color3.fromRGB(235, 240, 254),
		purple    = Color3.fromRGB(147, 51, 234),   -- --track-build #9333ea
		purpleBg  = Color3.fromRGB(245, 237, 254),
		orange    = Color3.fromRGB(245, 158, 11),
		orangeBg  = Color3.fromRGB(254, 246, 231),
		gold      = Color3.fromRGB(234, 179, 8),
		red       = Color3.fromRGB(239, 68, 68),
		redBg     = Color3.fromRGB(254, 238, 238),

		-- Assombris par rapport aux tokens du site : dans Studio, le panneau
		-- est petit et souvent regardé de biais. Le gris clair du web (#6b7280
		-- / #9ca3af) y devient illisible — surtout textMuted, qui portait la
		-- barre de contexte et le compteur.
		text      = Color3.fromRGB(38, 46, 60),     -- était (55,65,81)
		textSec   = Color3.fromRGB(71, 82, 98),     -- était (107,114,128)
		textMuted = Color3.fromRGB(107, 118, 134),  -- était (156,163,175)
		textBright= Color3.fromRGB(11, 16, 27),     -- était (17,24,39)

		white     = Color3.fromRGB(255, 255, 255),
		black     = Color3.fromRGB(17, 24, 39),
	},
}

local C = {}
local currentTheme = "dark"
local function applyTheme(name)
	if not THEMES[name] then name = "dark" end
	currentTheme = name
	for k, v in pairs(THEMES[name]) do C[k] = v end
end

-- ═══════════════════ COLORATION SYNTAXIQUE LUAU ═══════════════════════
-- Les blocs de code de Bloxi s'affichent dans un TextLabel RichText. Les
-- teintes suivent celles de l'éditeur de Studio pour que l'élève retrouve
-- ses repères, avec deux variantes : sur fond sombre le panneau utilise
-- C.bg, sur fond clair C.bgSurface — le même jeu de couleurs y serait
-- illisible.
local SYNTAX = {
	dark = {
		kw = "#f472b6", str = "#7ee787", num = "#f0883e",
		com = "#6b7f99", glob = "#79c0ff", fn = "#d2a8ff",
	},
	light = {
		kw = "#b02a75", str = "#0a7f3f", num = "#a04100",
		com = "#6b7684", glob = "#0b4fc4", fn = "#6b28c4",
	},
}

-- Mots-clés Luau + valeurs littérales.
local LUAU_KEYWORDS = {
	["and"]=1,["break"]=1,["do"]=1,["else"]=1,["elseif"]=1,["end"]=1,
	["false"]=1,["for"]=1,["function"]=1,["if"]=1,["in"]=1,["local"]=1,
	["nil"]=1,["not"]=1,["or"]=1,["repeat"]=1,["return"]=1,["then"]=1,
	["true"]=1,["until"]=1,["while"]=1,["continue"]=1,["self"]=1,
}
-- Globals Roblox les plus courants dans les cours : les mettre en évidence
-- aide à distinguer « ce que Roblox fournit » de « ce que j'ai écrit ».
local LUAU_GLOBALS = {
	["game"]=1,["workspace"]=1,["script"]=1,["Instance"]=1,["Vector3"]=1,
	["Vector2"]=1,["CFrame"]=1,["Color3"]=1,["UDim2"]=1,["UDim"]=1,
	["Enum"]=1,["task"]=1,["math"]=1,["string"]=1,["table"]=1,["os"]=1,
	["tick"]=1,["wait"]=1,["print"]=1,["warn"]=1,["error"]=1,["pairs"]=1,
	["ipairs"]=1,["tostring"]=1,["tonumber"]=1,["type"]=1,["typeof"]=1,
	["pcall"]=1,["require"]=1,["BrickColor"]=1,["TweenInfo"]=1,
}

-- RichText interprète < > & : sans échappement, un `a < b` casserait tout le
-- balisage et le label afficherait n'importe quoi.
local function escapeRich(s)
	return (s:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;"))
end

-- Colorise du Luau en RichText. Écrit un tokeniseur minimal plutôt qu'une
-- succession de gsub : une regex sur les mots-clés colorierait aussi le mot
-- « end » à l'intérieur d'une chaîne ou d'un commentaire.
local function highlightLuau(code)
	local P = SYNTAX[currentTheme] or SYNTAX.dark
	local out, i, n = {}, 1, #code
	local function push(color, txt)
		if color then
			out[#out + 1] = '<font color="' .. color .. '">' .. escapeRich(txt) .. "</font>"
		else
			out[#out + 1] = escapeRich(txt)
		end
	end
	while i <= n do
		local c = code:sub(i, i)
		-- Commentaire (ligne ou bloc --[[ ]])
		if code:sub(i, i + 1) == "--" then
			local j
			local blockEq = code:match("^%-%-%[(=*)%[", i)
			if blockEq then
				local close = "]" .. blockEq .. "]"
				local e = code:find(close, i, true)
				j = e and (e + #close - 1) or n
			else
				j = code:find("\n", i) or (n + 1)
				j = j - 1
			end
			push(P.com, code:sub(i, j)); i = j + 1
		-- Chaîne longue [[ ]]
		elseif code:match("^%[(=*)%[", i) then
			local eq = code:match("^%[(=*)%[", i)
			local close = "]" .. eq .. "]"
			local e = code:find(close, i, true)
			local j = e and (e + #close - 1) or n
			push(P.str, code:sub(i, j)); i = j + 1
		-- Chaîne " ou '
		elseif c == '"' or c == "'" then
			local j, esc = i + 1, false
			while j <= n do
				local ch = code:sub(j, j)
				if esc then esc = false
				elseif ch == "\\" then esc = true
				elseif ch == c then break
				elseif ch == "\n" then break end
				j = j + 1
			end
			push(P.str, code:sub(i, math.min(j, n))); i = j + 1
		-- Nombre
		elseif c:match("%d") then
			local j = i
			while j <= n and code:sub(j, j):match("[%w%.xXa-fA-F]") do j = j + 1 end
			push(P.num, code:sub(i, j - 1)); i = j
		-- Identifiant / mot-clé
		elseif c:match("[%a_]") then
			local j = i
			while j <= n and code:sub(j, j):match("[%w_]") do j = j + 1 end
			local word = code:sub(i, j - 1)
			-- Un mot suivi de « ( » est un appel : couleur dédiée, sauf si
			-- c'est un mot-clé (function(), if(...)).
			local nextChar = code:match("^%s*(%(?)", j)
			if LUAU_KEYWORDS[word] then push(P.kw, word)
			elseif LUAU_GLOBALS[word] then push(P.glob, word)
			elseif nextChar == "(" then push(P.fn, word)
			else push(nil, word) end
			i = j
		else
			push(nil, c); i = i + 1
		end
	end
	return table.concat(out)
end

-- Échelle de rayons du site (--radius-*), pour que les arrondis du plugin
-- correspondent à ceux des cartes web.
local R = { xs = 4, ["2xs"] = 6, sm = 8, md = 10, lg = 14, xl = 20, pill = 999 }

-- Charge le thème sauvegardé (sombre par défaut)
do
	local ok, saved = pcall(function() return plugin:GetSetting("LB_theme") end)
	applyTheme((ok and (saved == "light" or saved == "dark")) and saved or "dark")
end

-- ═══════════════════════════════ STATE ════════════════════════════════
local state = {
	username = nil,
	userId = nil,
	currentModule = nil,
	exercises = {},
	gen = 0, -- génération d'écran (annule les loops/async obsolètes)
	-- Stats de profil renvoyées par /me (affichées dans l'en-tête)
	streak = 0,
	-- Record de série, renvoyé par /me. Affiché sur l'accueil à côté de la
	-- série en cours : sans lui, on ne sait pas si on est en train de battre
	-- son record ou loin derrière.
	bestStreak = 0,
	premium = false,
	-- Mode d'accès aux modules, réglé par l'utilisateur sur le site :
	-- "progressive" (déblocage séquentiel, défaut) ou "free" (tout ouvert).
	moduleAccess = "progressive",
	-- Cache des modules : évite le spinner à chaque retour sur l'accueil
	modulesCache = nil,
	-- Conversation Bloxi { {role="user"|"assistant", content=…}, … }.
	-- Vit dans le state et non dans l'écran : changer d'onglet ne doit pas
	-- effacer la discussion en cours. Perdue au rechargement du plugin, ce qui
	-- est le comportement attendu d'un assistant de session.
	bloxiChat = nil,
	-- Mode agent actif (Premium) : Bloxi propose des actions applicables d'un
	-- clic au lieu de seulement répondre. Mémorisé via LB_bloxi_agent.
	agentMode = false,
	-- Dernier plantage analysé au playtest {exId, what, fix, line}, affiché
	-- en carte de diagnostic sur l'écran de l'exercice concerné.
	lastDiag = nil,
	-- ── Mode développement ──
	-- `dev` vient de /me (rôle admin en base). Le plugin ne fait que
	-- l'afficher : le privilège réel (quota illimité) est appliqué en SQL,
	-- donc modifier cette valeur ici ne débloque rien.
	dev = false,
	-- Mode test : n'envoie RIEN au serveur, répond localement. Sert à
	-- travailler l'interface (bulles, cartes d'action, états d'erreur) sans
	-- consommer de messages ni attendre le modèle.
	devTestMode = false,
	-- Journal des appels API, alimenté seulement quand `dev` est vrai.
	-- { {t=..., method=..., endpoint=..., status=..., ms=...}, … }
	devLogs = {},
}

-- ═══════════════════════════════ PLUGIN ═══════════════════════════════
-- Le plugin se charge dans TOUS les DataModels (Edit + serveur/client de
-- playtest). L'UI n'est créée qu'en Edit ; la capture des logs se fait dans
-- le serveur de playtest. Bootstrap conscient du DataModel en bas du fichier.
local toolbar, toggleBtn, widget
-- Rafraîchit la conversation Bloxi depuis l'extérieur d'init() (veille du
-- script). Défini par init() ; nil tant que le panneau n'a pas été ouvert.
local _refreshBloxiFeed

-- Icône du bouton de la barre d'outils Studio.
-- Roblox n'accepte QU'UN asset ID ici : ni chemin local, ni URL, ni base64.
-- Il faut donc téléverser l'image sur roblox.com (Créations → Decals), puis
-- coller l'ID obtenu ci-dessous sous la forme "rbxassetid://123456789".
--
-- Format recommandé : PNG carré 512×512, fond TRANSPARENT. Studio l'affiche
-- en 32×32 dans la barre d'outils et applique sa propre teinte selon le
-- thème : un logo sur fond blanc apparaîtra comme un carré blanc.
--
-- Tant que la chaîne est vide, Studio affiche le losange gris par défaut.
local PLUGIN_ICON = ""

-- ═══════════════════════════════ UTILS ════════════════════════════════
local function shade(color, amt)
	return Color3.new(
		math.clamp(color.R + amt, 0, 1),
		math.clamp(color.G + amt, 0, 1),
		math.clamp(color.B + amt, 0, 1)
	)
end

-- Glyphes de module : l'API renvoie un nom d'icône Lucide (comme le site),
-- qu'on transpose en texte court affichable dans Studio.
-- IMPORTANT : uniquement de l'ASCII. La police du plugin (Gotham) ne couvre
-- pas les pictogrammes Unicode (⚡ ⛨ ♟ ƒ …) : ils sortent en carré « tofu ».
local ICONS = {
	monitor = "[ ]", code = "</>", ["git-branch"] = "Y",
	braces = "{ }", database = "DB", ["function"] = "fx",
	loop = "for", table = "{,}", event = "on", part = "3D",
	rocket = "^", star = "*", puzzle = "+",
	zap = "!", box = "[ ]", layers = "=", play = ">",
	users = "@", shield = "S", settings = "#", default = "*",
}
local function iconFor(name) return ICONS[name] or ICONS.default end

-- ── Vérification automatique d'une étape de projet ─────────────────────
-- Inspecte la place Studio ouverte selon une règle `check` (data-driven).
-- Retourne (ok:boolean, message:string?). Fonctionne en édition (structure
-- d'instances) — pas besoin de lancer le jeu pour les étapes de construction.
local function resolveContainer(name)
	if not name or name == "" or name == "Workspace" or name == "workspace" then
		return workspace
	end
	local ok, svc = pcall(function() return game:GetService(name) end)
	if ok and svc then return svc end
	return game:FindFirstChild(name) or workspace
end

-- Résout une instance par CHEMIN EXACT (ex: "Workspace.Zones_Recolte.Zone_Ble").
-- Pas de recherche récursive : l'objet doit être exactement au bon endroit.
local function resolveByPath(path)
	if type(path) ~= "string" or path == "" then return nil end
	local segments = string.split(path, ".")
	local current
	local start = segments[1]
	if start == "Workspace" or start == "workspace" then
		current = workspace
		table.remove(segments, 1)
	else
		local okSvc, svc = pcall(function() return game:GetService(start) end)
		if okSvc and svc then
			current = svc
			table.remove(segments, 1)
		else
			current = workspace -- chemin relatif au Workspace
		end
	end
	for _, seg in ipairs(segments) do
		if not current then return nil end
		current = current:FindFirstChild(seg)
	end
	return current
end

-- Vérifie qu'une instance a les propriétés attendues (tolérance sur Size/Position).
local function propsMatch(inst, props)
	if type(props) ~= "table" then return true end
	for k, v in pairs(props) do
		local ok, matched = pcall(function()
			if k == "Size" or k == "Position" then
				local target = Vector3.new(v[1], v[2], v[3])
				local tol = v[4] or 4
				return (inst[k] - target).Magnitude <= tol
			elseif k == "Material" then
				return inst.Material == Enum.Material[v]
			elseif k == "BrickColor" then
				return tostring(inst.BrickColor) == v
			elseif k == "MinSize" then
				return inst.Size.X >= v[1] and inst.Size.Y >= v[2] and inst.Size.Z >= v[3]
			else
				return inst[k] == v
			end
		end)
		if not ok or not matched then return false end
	end
	return true
end

-- Récupère le code le plus à jour (document ouvert prioritaire sur .Source)
local function getScriptSource(s)
	-- ScriptEditorService:FindScriptDocument n'existe qu'en mode Edit.
	-- En playtest (serveur ou client), on tombe sur nil → on utilise .Source.
	local ok, txt = pcall(function()
		local doc = ScriptEditorService:FindScriptDocument(s)
		if doc then return doc:GetText() end
		return nil
	end)
	if ok and txt then return txt end
	-- Fallback : propriété Source (toujours disponible côté serveur de test)
	local okSrc, src = pcall(function() return s.Source end)
	return okSrc and src or ""
end

-- Vérification stricte d'une étape. Retourne (ok, message).
-- Chaque `req` peut cibler par `path` (chemin exact, recommandé) ou `name`
-- (recherche récursive, hérité). On peut exiger `class` et des `props`.
local function runStepCheck(check, outputBuffer)
	if type(check) ~= "table" then return false, "Aucune vérification définie" end
	if check.type == "instances" then
		local missing = {}
		for _, req in ipairs(check.all or {}) do
			local inst
			if req.path then
				inst = resolveByPath(req.path)
			elseif req.name then
				local container = resolveContainer(check.parent)
				inst = container:FindFirstChild(req.name, true)
			end
			local okReq = inst ~= nil
			if okReq and req.class and req.class ~= "" then okReq = inst:IsA(req.class) end
			if okReq and req.props then okReq = propsMatch(inst, req.props) end
			if not okReq then table.insert(missing, req.path or req.name or "?") end
		end
		if #missing == 0 then return true end
		return false, "Manque / incorrect : " .. table.concat(missing, ", ")
	elseif check.type == "output" then
		-- Vérifie que tous les textes attendus apparaissent dans la sortie du jeu
		local buf = outputBuffer or {}
		local fullOutput = table.concat(buf, "\n")
		local missing = {}
		for _, expected in ipairs(check.contains or {}) do
			if not fullOutput:find(expected, 1, true) then
				table.insert(missing, expected)
			end
		end
		if #missing == 0 then return true end
		return false, "Sortie manquante : " .. table.concat(missing, ", ")
	elseif check.type == "code" then
		-- Vérifie que le code source d'un script contient certains patterns
		local scriptPath = check.script -- ex: "ServerScriptService.FermeScript"
		local inst = resolveByPath(scriptPath)
		if not inst or not inst:IsA("LuaSourceContainer") then
			return false, "Script introuvable : " .. (scriptPath or "?")
		end
		local source = getScriptSource(inst)
		if not source or source == "" then
			return false, "Script vide : " .. (scriptPath or "?")
		end
		local missing = {}
		for _, pattern in ipairs(check.contains or {}) do
			if not source:find(pattern, 1, true) then
				table.insert(missing, pattern)
			end
		end
		if #missing == 0 then return true end
		return false, "Code manquant : " .. table.concat(missing, ", ")
	end
	-- type inconnu : on NE valide PAS (évite les faux positifs)
	return false, "Vérification automatique indisponible pour cette étape"
end

-- ── Génération automatique de la structure d'une étape (data-driven) ───
-- Crée les Parts/dossiers décrits par `apply` avec leurs propriétés, pour
-- que l'élève passe la partie "setup" mécanique et se concentre sur la
-- logique. Ne génère JAMAIS de scripts (l'apprentissage reste manuel).
-- Conteneurs qui NE SONT PAS des services : game:GetService() échoue dessus.
-- Sans cette table, « StarterPlayerScripts » n'était résolu nulle part et on
-- finissait par créer un Folder de ce nom DANS Workspace — un dossier fantôme
-- qui ne fait rien, alors que Roblox attend ces scripts sous StarterPlayer.
local NON_SERVICE_CONTAINERS = {
	StarterPlayerScripts = { "StarterPlayer", "StarterPlayerScripts" },
	StarterCharacterScripts = { "StarterPlayer", "StarterCharacterScripts" },
}

-- Résout un conteneur par son nom, en gérant les enfants de service.
local function _resolveNamedContainer(name)
	if name == "Workspace" or name == "workspace" or name == "game" then return workspace end

	local path = NON_SERVICE_CONTAINERS[name]
	if path then
		local okP, node = pcall(function()
			local n = game:GetService(path[1])
			return n and n:FindFirstChild(path[2])
		end)
		if okP and node then return node end
		return nil
	end

	local okSvc, svc = pcall(function() return game:GetService(name) end)
	if okSvc and svc then return svc end
	return nil
end

local function _resolveOrCreateParent(name)
	if not name or name == "" or name == "Workspace" or name == "workspace" then
		return workspace
	end

	-- Le modèle écrit parfois une EXPRESSION LUAU au lieu d'un chemin :
	--   "Players.LocalPlayer:WaitForChild('PlayerGui')"
	--   "game:GetService('StarterGui')"
	-- Traitée comme un chemin pointé, elle produisait une cascade de dossiers
	-- au nom absurde dans Workspace. On la ramène à ce qu'elle DÉSIGNE.
	do
		local s = tostring(name)
		-- L'interface d'un joueur au runtime = StarterGui côté édition : c'est
		-- StarterGui qui est cloné dans PlayerGui à l'arrivée du joueur.
		if s:find("PlayerGui", 1, true) then
			local okG, g = pcall(function() return game:GetService("StarterGui") end)
			if okG and g then return g end
		end
		-- game:GetService("X") / game:GetService('X') → X
		local svc = s:match("GetService%s*%(%s*['\"]([%w_]+)['\"]%s*%)")
		if svc then
			local okS, node = pcall(function() return game:GetService(svc) end)
			if okS and node then return node end
		end
		-- WaitForChild("X") / FindFirstChild("X") → on garde le nom cherché
		local child = s:match("[WF][ai][in][td][^%(]*%(%s*['\"]([%w_]+)['\"]")
		if child then
			local hit = _resolveNamedContainer(child)
			if hit then return hit end
			-- Sinon on repart du nom seul, pas de l'expression entière.
			s = child
			name = child
		end
		-- Reste un appel de méthode ou des parenthèses : ce n'est PAS un chemin
		-- exploitable. On refuse plutôt que de fabriquer des dossiers fantômes.
		if name:find("[%(%):]") then
			return workspace
		end
	end

	local direct = _resolveNamedContainer(name)
	if direct then return direct end

	-- Chemin pointé (« Workspace.Plateforme », « StarterPlayer.StarterPlayerScripts ») :
	-- sans ça, on créait un dossier vide littéralement nommé avec le chemin.
	if name:find(".", 1, true) then
		local node
		for seg in name:gmatch("[^%.]+") do
			if not node then
				node = _resolveNamedContainer(seg) or workspace:FindFirstChild(seg, true)
			else
				local nxt = node:FindFirstChild(seg)
				-- Segment manquant en cours de route : on le CRÉE comme dossier
				-- au bon endroit, plutôt que d'abandonner et de tout déverser
				-- dans Workspace. « BoutiqueGui.BoutonBoost » doit donner un
				-- BoutonBoost dans BoutiqueGui, pas un dossier au nom composé.
				if not nxt then
					local f = Instance.new("Folder")
					f.Name = seg
					f.Parent = node
					nxt = f
				end
				node = nxt
			end
			if not node then break end
		end
		if node then return node end
	end

	-- Recherche dans TOUS les conteneurs de contenu, pas seulement Workspace.
	-- Un « CoinGui » vit dans StarterGui : ne chercher que dans Workspace le
	-- déclarait introuvable, et on créait un Folder du même nom dans Workspace
	-- — d'où l'interface éparpillée entre un vrai ScreenGui vide et un dossier
	-- fantôme contenant les Frames.
	for _, svcName in ipairs({
		"StarterGui", "ServerScriptService", "ReplicatedStorage", "ServerStorage",
		"StarterPack", "Lighting", "ReplicatedFirst",
	}) do
		local okSvc, svc = pcall(function() return game:GetService(svcName) end)
		if okSvc and svc then
			local hit = svc:FindFirstChild(name, true)
			if hit then return hit end
		end
	end
	local okSP, sp = pcall(function() return game:GetService("StarterPlayer") end)
	if okSP and sp then
		local hit = sp:FindFirstChild(name, true)
		if hit then return hit end
	end

	local found = workspace:FindFirstChild(name, true) -- récursif
	if found then return found end

	-- Vraiment introuvable : dossier de secours dans Workspace. C'est le
	-- dernier recours, il ne devrait presque jamais servir.
	local f = Instance.new("Folder"); f.Name = name; f.Parent = workspace
	return f
end

-- Déclaré en avance : _setInstanceProps l'utilise pour la propriété Source,
-- mais la définition complète vient plus bas (elle dépend de ScriptEditorService).
local setScriptSource

-- Convertit une valeur de propriété venue du modèle en type Roblox.
-- Le modèle écrit tantôt un tableau ([4,1,4]), tantôt la syntaxe Luau
-- ("Vector3.new(4,1,4)"), tantôt un nom d'enum ("Neon"). Tout accepter
-- coûte quelques motifs ici et évite une action qui échoue pour un
-- simple écart de forme. (Approche reprise du plugin BloxMi.)
local function _parsePropValue(v)
	if type(v) == "number" or type(v) == "boolean" then return v end

	-- Tableau : [x,y,z] (Vector3) ou [r,g,b] (couleur, décidé par l'appelant)
	if type(v) == "table" then return v end

	if type(v) ~= "string" then return v end
	local s = v

	local x, y, z = s:match("Vector3%.new%(%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*%)")
	if x then return Vector3.new(tonumber(x), tonumber(y), tonumber(z)) end

	local x2, y2 = s:match("Vector2%.new%(%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*%)")
	if x2 then return Vector2.new(tonumber(x2), tonumber(y2)) end

	local r, g, b = s:match("Color3%.fromRGB%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*%)")
	if r then return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b)) end

	r, g, b = s:match("Color3%.new%(%s*([%d%.]+)%s*,%s*([%d%.]+)%s*,%s*([%d%.]+)%s*%)")
	if r then return Color3.new(tonumber(r), tonumber(g), tonumber(b)) end

	local a, bb, c, d = s:match("UDim2%.new%(%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*%)")
	if a then return UDim2.new(tonumber(a), tonumber(bb), tonumber(c), tonumber(d)) end

	a, bb = s:match("UDim%.new%(%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*%)")
	if a then return UDim.new(tonumber(a), tonumber(bb)) end

	local bcname = s:match("BrickColor%.new%(['\"]([^'\"]+)['\"]%)")
	if bcname then return BrickColor.new(bcname) end

	local enumType, enumName = s:match("Enum%.([A-Za-z]+)%.([A-Za-z0-9_]+)")
	if enumType and enumName then
		local okE, enumVal = pcall(function() return Enum[enumType][enumName] end)
		if okE then return enumVal end
	end

	if s == "true" then return true end
	if s == "false" then return false end

	local num = tonumber(s)
	if num then return num end

	return s
end

local function _setInstanceProps(inst, props)
	if type(props) ~= "table" then return end
	for k, v in pairs(props) do
		pcall(function()
			if k == "Source" and inst:IsA("LuaSourceContainer") then
				-- Passe par setScriptSource : écrire .Source directement n'a
				-- aucun effet quand le script est ouvert dans l'éditeur.
				setScriptSource(inst, tostring(v))
			elseif k == "Size" or k == "Position" or k == "Orientation" then
				-- ATTENTION : Size/Position n'ont PAS le même type selon l'objet.
				--   objet 3D (Part…)  → Vector3
				--   objet 2D (GUI)    → UDim2
				-- Fabriquer un Vector3 sur un Frame lève une erreur que le pcall
				-- avale en silence : la propriété n'est jamais appliquée, et le
				-- GUI garde sa taille/position par défaut — donc collé dans un
				-- coin, minuscule ou hors écran. C'était la cause des interfaces
				-- « créées mais invisibles ».
				local is2D = inst:IsA("GuiObject") or inst:IsA("UIComponent")
				if type(v) == "table" then
					if is2D then
						-- [xScale, xOffset, yScale, yOffset] ; un tableau à 2
						-- valeurs est lu comme des offsets purs.
						if #v >= 4 then
							inst[k] = UDim2.new(v[1], v[2], v[3], v[4])
						else
							inst[k] = UDim2.new(0, v[1] or 0, 0, v[2] or 0)
						end
					else
						inst[k] = Vector3.new(v[1], v[2], v[3])
					end
				else
					local parsed = _parsePropValue(v)
					-- Dernier filet : si le modèle a écrit "Vector3.new(...)" sur
					-- un GUI (ou l'inverse), on convertit au lieu d'échouer.
					if is2D and typeof(parsed) == "Vector3" then
						parsed = UDim2.new(0, parsed.X, 0, parsed.Y)
					elseif not is2D and typeof(parsed) == "UDim2" then
						parsed = Vector3.new(parsed.X.Offset, parsed.Y.Offset, 0)
					end
					inst[k] = parsed
				end
			elseif k == "Color" then
				if type(v) == "table" then
					inst[k] = Color3.fromRGB(v[1], v[2], v[3])
				else
					inst[k] = _parsePropValue(v)
				end
			elseif k == "BrickColor" then
				inst.BrickColor = (typeof(v) == "BrickColor") and v or BrickColor.new(tostring(v))
			elseif k == "Material" then
				inst.Material = (typeof(v) == "EnumItem") and v or (Enum.Material[tostring(v)] or _parsePropValue(v))
			elseif k == "Shape" then
				inst.Shape = (typeof(v) == "EnumItem") and v or (Enum.PartType[tostring(v)] or _parsePropValue(v))
			else
				inst[k] = _parsePropValue(v)
			end
		end)
	end
end

-- Retourne (ok, message). Idempotent : réutilise une instance existante de
-- même nom plutôt que d'en créer un doublon.
local function applyStepSetup(apply)
	if type(apply) ~= "table" then return false, "Rien à générer" end
	-- suppressions éventuelles (ex: Baseplate d'origine)
	for _, rm in ipairs(apply.remove or {}) do
		local target = rm.name and (workspace:FindFirstChild(rm.name, true) or game:FindFirstChild(rm.name)) or nil
		if target then pcall(function() target:Destroy() end) end
	end
	-- créations
	--
	-- ORDRE DES DÉPENDANCES : un item dont le parent est créé par un AUTRE item
	-- du même lot doit passer APRÈS lui. Sans ce tri, « crée CoinGui dans
	-- StarterGui » puis « crée CoinFrame dans CoinGui » échouait : au moment de
	-- résoudre « CoinGui », le ScreenGui n'existait pas encore, et on
	-- fabriquait un Folder de secours dans Workspace.
	local items = {}
	for _, it in ipairs(apply.create or {}) do table.insert(items, it) end
	do
		-- Un item est « produit » par le lot si son nom y figure comme création.
		local producedBy = {}
		for i, it in ipairs(items) do
			if it.name and not producedBy[it.name] then producedBy[it.name] = i end
		end
		-- Tri stable : on place chaque item après celui qui crée son parent.
		local ordered, placed = {}, {}
		local function place(i, depth)
			if placed[i] or depth > 8 then return end -- depth : garde-fou anti-cycle
			placed[i] = true
			local it = items[i]
			-- Le parent peut être un chemin (« StarterGui.CoinGui ») : on ne
			-- regarde que le dernier segment, c'est lui qui porte le nom.
			local pname = it.parent and tostring(it.parent):match("([^.]+)$") or nil
			local dep = pname and producedBy[pname]
			if dep and dep ~= i then place(dep, depth + 1) end
			table.insert(ordered, it)
		end
		for i = 1, #items do place(i, 0) end
		items = ordered
	end

	local created = 0
	for _, item in ipairs(items) do
		local parent = _resolveOrCreateParent(item.parent)
		local cls = item.class or "Part"
		local inst = item.name and parent:FindFirstChild(item.name) or nil
		if not inst then
			local okNew, res = pcall(function() return Instance.new(cls) end)
			if okNew and res then
				inst = res
				if item.name then inst.Name = item.name end
				inst.Parent = parent
			end
		end
		if inst then
			_setInstanceProps(inst, item.props)

			-- ── Filet de sécurité pour les interfaces ──
			-- Un GuiObject créé sans Size ni Position part avec les valeurs par
			-- défaut de Roblox : (0,0,0,0), donc INVISIBLE — collé en haut à
			-- gauche, de taille nulle. L'utilisateur voit l'objet dans
			-- l'Explorer mais rien à l'écran, ce qui est la pire des situations.
			-- On ne force RIEN de ce que le modèle a fourni : on ne comble que
			-- ce qui manque.
			pcall(function()
				local p = item.props or {}
				if inst:IsA("ScreenGui") then
					-- Sans ResetOnSpawn=false, l'interface disparaît à chaque mort.
					if p.ResetOnSpawn == nil then inst.ResetOnSpawn = false end
					if p.IgnoreGuiInset == nil then inst.IgnoreGuiInset = true end
				elseif inst:IsA("GuiObject") then
					-- Taille nulle = invisible. On donne une taille lisible.
					if p.Size == nil and inst.Size == UDim2.new() then
						inst.Size = inst:IsA("TextLabel") and UDim2.new(1, 0, 1, 0)
							or UDim2.new(0, 200, 0, 60)
					end
					-- Un enfant direct de ScreenGui sans Position finirait dans le
					-- coin haut-gauche, souvent sous le bouton Roblox : on le
					-- décale pour qu'il soit visible tout de suite.
					if p.Position == nil and inst.Parent and inst.Parent:IsA("ScreenGui")
						and inst.Position == UDim2.new() then
						inst.Position = UDim2.new(0, 16, 0, 16)
					end
					-- Un texte sans taille lisible est illisible sur mobile.
					if inst:IsA("TextLabel") or inst:IsA("TextButton") then
						if p.TextScaled == nil and p.TextSize == nil then
							inst.TextScaled = true
						end
					end
				end
			end)

			created += 1
		end
	end
	if created == 0 and #items > 0 then
		return false, "Génération impossible"
	end
	return true
end

-- Écrit le code dans un script même s'il est ouvert dans l'éditeur.
-- (Modifier script.Source n'a aucun effet quand le document est ouvert :
--  il faut éditer le ScriptDocument live via ScriptEditorService.)
function setScriptSource(s, src)
	local doc = ScriptEditorService:FindScriptDocument(s)
	if doc then
		local ok = pcall(function()
			local lineCount = doc:GetLineCount()
			local lastLine = doc:GetLine(lineCount) or ""
			doc:EditTextAsync(src, 1, 1, lineCount, (utf8.len(lastLine) or #lastLine) + 1)
		end)
		if not ok then s.Source = src end
	else
		s.Source = src
	end
end

-- ── Insertion de code proposée par Bloxi ────────────────────────────────
-- Cible d'insertion : le script OUVERT dans l'éditeur d'abord (c'est celui
-- que l'élève regarde), la sélection de l'Explorer ensuite. L'ancienne
-- version ne regardait que la sélection : quand on écrit dans un script sans
-- l'avoir re-cliqué dans l'Explorer, la sélection est vide ou pointe autre
-- part — d'où du code atterrissant « au mauvais endroit ».
-- Retourne (script, document|nil).
function _resolveInsertTarget()
	local doc
	pcall(function() doc = ScriptEditorService:FindScriptDocument(nil) end)
	if doc then
		local okS, scr = pcall(function() return doc:GetScript() end)
		if okS and scr then return scr, doc end
	end
	local target
	pcall(function()
		for _, inst in ipairs(Selection:Get() or {}) do
			if inst:IsA("LuaSourceContainer") then target = inst break end
		end
	end)
	if not target then return nil, nil end
	-- Le script sélectionné est peut-être ouvert : on récupère son document
	-- pour pouvoir viser le curseur plutôt que la fin du fichier.
	local okD, d = pcall(function() return ScriptEditorService:FindScriptDocument(target) end)
	return target, (okD and d) or nil
end

-- Réindente un bloc sur l'indentation de la ligne d'insertion : collé à
-- l'intérieur d'un `if` ou d'une fonction, le code doit suivre le niveau
-- courant, sinon il est illisible (et parfois faux à l'œil du débutant).
local function _reindent(code, indent)
	if indent == "" then return code end
	local out = {}
	for line in (code .. "\n"):gmatch("([^\n]*)\n") do
		-- On n'indente pas les lignes vides : ça laisserait des espaces morts.
		out[#out + 1] = (line == "") and "" or (indent .. line)
	end
	-- gmatch a produit une dernière entrée vide (après le \n ajouté).
	if out[#out] == "" then out[#out] = nil end
	return table.concat(out, "\n")
end

-- Bloxi répond en donnant le script ENTIER, prêt à remplacer l'ancien (c'est
-- une consigne du prompt : un débutant ne sait pas recomposer un fichier à
-- partir de fragments). Insérer un tel bloc au curseur dupliquerait tout le
-- fichier. Heuristique : si le script existant est non trivial et que le bloc
-- en reprend la première ligne significative, c'est un remplacement.
local function _looksLikeFullScript(existing, code)
	local function firstMeaningful(src)
		for line in (src .. "\n"):gmatch("([^\n]*)\n") do
			local t = line:match("^%s*(.-)%s*$")
			if t ~= "" and not t:match("^%-%-") then return t end
		end
		return nil
	end
	local a, b = firstMeaningful(existing), firstMeaningful(code)
	if not a or not b then return false end
	-- Peu de lignes existantes → remplacer ou insérer revient au même ; on
	-- laisse l'insertion normale décider.
	local _, n = existing:gsub("\n", "")
	if n < 3 then return false end
	return a == b
end

-- Insère `code` à la position du curseur si le script est ouvert, sinon en
-- fin de fichier. Retourne (ok, description lisible de l'endroit).
function _insertCodeAt(target, doc, code)
	code = tostring(code or ""):gsub("\r\n", "\n"):gsub("%s+$", "")
	if code == "" then return false, "" end

	-- Script complet proposé → on remplace tout, sinon on créerait un doublon.
	local existing = getScriptSource(target)
	if _looksLikeFullScript(existing, code) then
		local okAll = pcall(function() setScriptSource(target, code .. "\n") end)
		if okAll then return true, "en remplaçant le script" end
	end

	if doc then
		-- GetSelection renvoie le DÉBUT et la FIN de la sélection
		-- (ligne/colonne x2). Curseur simple = début == fin.
		local okCur, sL, sC, eL, eC = pcall(function()
			return doc:GetSelection()
		end)
		if okCur and sL and eL then
			local ok = pcall(function()
				-- Texte sélectionné → on le REMPLACE : l'élève a désigné la
				-- zone à réécrire, insérer à côté laisserait du code en double.
				local hasSel = (sL ~= eL) or (sC ~= eC)
				local cur = doc:GetLine(sL) or ""
				-- Indentation de la ligne visée, reproduite sur tout le bloc.
				local indent = cur:match("^[ \t]*") or ""
				local body = _reindent(code, indent)
				if hasSel then
					-- Le bloc reprend l'indentation, mais la 1re ligne s'écrit
					-- à la position exacte du début de sélection.
					doc:EditTextAsync((body:gsub("^[ \t]+", "")), sL, sC, eL, eC)
				elseif cur:match("^%s*$") then
					-- Ligne vide → on écrit dedans, pas en dessous.
					doc:EditTextAsync(body, sL, 1, sL, (utf8.len(cur) or #cur) + 1)
				else
					-- Ligne déjà remplie → on insère APRÈS elle, pour ne pas
					-- couper une instruction en deux.
					local endCol = (utf8.len(cur) or #cur) + 1
					doc:EditTextAsync("\n" .. body, sL, endCol, sL, endCol)
				end
			end)
			if ok then return true, "à la ligne " .. sL end
		end
	end

	-- Repli : pas de document ouvert (ou curseur illisible) → fin du script.
	local ok = pcall(function()
		local cur = getScriptSource(target)
		local sep = (cur ~= "" and not cur:match("\n%s*$")) and "\n\n" or ""
		setScriptSource(target, cur .. sep .. code .. "\n")
	end)
	return ok, "à la fin"
end

-- ═══════════════════════ DIAGNOSTIC D'ERREUR LUAU ═════════════════════
-- Quand le code de l'élève plante au playtest, l'erreur Roblox part dans
-- l'Output de Studio — une fenêtre que les débutants n'ouvrent pas et ne
-- savent pas lire. On la récupère, on la traduit en français et on explique
-- quoi faire. On n'écrit JAMAIS la correction : on nomme le problème et on
-- oriente, l'élève garde l'effort de résolution.

-- Règles ordonnées : la première dont le motif correspond gagne. `what`
-- nomme l'erreur, `fix` dit par où commencer à chercher.
local ERROR_RULES = {
	{
		match = "attempt to index nil with '([%w_]+)'",
		what = "Tu utilises « %s » sur quelque chose qui n'existe pas (nil).",
		fix = "L'objet avant le point n'a pas été trouvé. Vérifie l'orthographe exacte du nom dans l'Explorer, et que l'objet existe bien au moment où le script s'exécute.",
	},
	{
		match = "attempt to index nil",
		what = "Tu accèdes à une propriété d'une valeur vide (nil).",
		fix = "Une variable que tu utilises n'a jamais reçu de valeur, ou l'objet cherché est introuvable. Affiche-la avec print() juste avant pour voir ce qu'elle contient.",
	},
	{
		match = "attempt to call a nil value",
		what = "Tu appelles une fonction qui n'existe pas.",
		fix = "Vérifie le nom de la fonction (les majuscules comptent) et qu'elle est bien définie AVANT la ligne où tu l'appelles.",
	},
	{
		match = "attempt to perform arithmetic %([^%)]*%) on (%a+)",
		what = "Tu fais un calcul avec une valeur de type « %s », pas un nombre.",
		fix = "On ne peut additionner que des nombres. Si la valeur vient d'un texte, convertis-la avec tonumber().",
	},
	{
		match = "attempt to concatenate %([^%)]*%) (%a+)",
		what = "Tu colles du texte avec une valeur de type « %s ».",
		fix = "L'opérateur .. joint des textes. Convertis l'autre valeur avec tostring() avant de la coller.",
	},
	{
		match = "attempt to compare",
		what = "Tu compares deux valeurs de natures différentes.",
		fix = "On ne peut comparer (< ou >) que des valeurs de même type, par exemple deux nombres. Vérifie ce que contient chaque côté.",
	},
	{
		match = "Expected '([^']+)'",
		what = "Il manque « %s » dans ton code.",
		fix = "C'est une erreur de frappe : une parenthèse, une virgule ou un guillemet oublié. Regarde la ligne indiquée et celle juste au-dessus.",
	},
	{
		match = "Expected identifier",
		what = "Le code s'arrête là où Roblox attendait un nom.",
		fix = "Il manque probablement un nom de variable ou de fonction, ou un mot-clé est mal orthographié.",
	},
	{
		match = "Unexpected symbol near '([^']+)'",
		what = "Le symbole « %s » n'est pas attendu ici.",
		fix = "Vérifie la ponctuation autour : parenthèse en trop, virgule mal placée, ou opérateur incomplet.",
	},
	{
		match = "'end' expected",
		what = "Il manque un « end » pour fermer un bloc.",
		fix = "Chaque if, for, while et function doit se refermer par end. Compte tes ouvertures et tes fermetures.",
	},
	{
		match = "Infinite yield possible on '([^']+)'",
		what = "L'attente sur « %s » ne se termine jamais.",
		fix = "WaitForChild attend un objet qui n'arrive pas. Vérifie l'orthographe du nom et l'endroit où tu le cherches.",
	},
	{
		match = "is not a valid member of",
		what = "Cette propriété n'existe pas sur cet objet.",
		fix = "Soit le nom est mal orthographié (les majuscules comptent), soit tu cibles le mauvais type d'objet.",
	},
	{
		match = "exhausted allowed execution time",
		what = "Ton script tourne sans jamais s'arrêter.",
		fix = "Une boucle ne se termine pas. Vérifie sa condition d'arrêt, et ajoute task.wait() dans les boucles while.",
	},
}

-- ═══════════════ BASE DE CONNAISSANCE LOCALE (LB_KNOWLEDGE) ═══════════
-- Le plugin répond LUI-MÊME à ce qu'il sait, avant d'appeler l'IA. Trois
-- bénéfices : réponse instantanée, aucun quota consommé, et ça marche même
-- quand le serveur d'IA est saturé.
--
-- Trois tables :
--   WATCH_RULES  : analyse statique du script en cours (erreurs et pièges)
--   QUICK_ANSWERS: réponses aux questions les plus fréquentes
--   CHEATSHEETS  : index des 17 antisèches du site, pour orienter
--
-- Les règles ont été validées hors ligne sur 44 cas de test et 7 scripts
-- réalistes complets (zéro faux positif) avant d'être portées ici.

-- ── Helpers d'analyse ────────────────────────────────────────────────────
-- Retire échappements, chaînes puis commentaires, dans CET ordre : couper le
-- commentaire trop tôt tronquerait une chaîne contenant « -- ».
local function _codeSeul(ligne)
	local s = ligne:gsub("\\.", "")
	s = s:gsub('"[^"]*"', '""'):gsub("'[^']*'", "''")
	return s:gsub("%-%-.*$", "")
end

local function _compte(s, motif)
	local n = 0
	for _ in s:gmatch(motif) do n += 1 end
	return n
end

local function _joint(lines, a, b)
	local out = {}
	for i = math.max(1, a), math.min(#lines, b) do out[#out + 1] = lines[i] end
	return table.concat(out, "\n")
end

-- ── Règles d'analyse ─────────────────────────────────────────────────────
-- `find(src, lines, meta)` renvoie (message, ligne) ou nil.
-- `meta.className` vaut "Script", "LocalScript" ou "ModuleScript".
-- ORDRE IMPORTANT : une chaîne non fermée fausse toutes les analyses
-- suivantes, elle passe donc en premier.
local WATCH_RULES = {
	-- ═══ SYNTAXE ═══
	{ id = "chaine_ouverte", find = function(_, lines)
		for i, l in ipairs(lines) do
			local s = l:gsub("\\.", "")
			s = s:gsub('"[^"]*"', ""):gsub("'[^']*'", "")
			s = s:gsub("%-%-.*$", "")
			if s:match("[\"']") then
				return "Il te manque un guillemet fermant : ta chaîne de texte reste ouverte.", i
			end
		end
	end },

	{ id = "blocs_non_fermes", find = function(_, lines)
		local o, e = 0, 0
		for _, l in ipairs(lines) do
			local c = _codeSeul(l)
			o += _compte(c, "%f[%w]function%f[%W]") + _compte(c, "%f[%w]then%f[%W]") + _compte(c, "%f[%w]do%f[%W]")
			e += _compte(c, "%f[%w]end%f[%W]")
			o -= _compte(c, "%f[%w]elseif%f[%W]")
		end
		if o - e >= 2 then
			return "Il te manque " .. (o - e) .. " « end » pour refermer tes blocs. Chaque if, for, while et function se referme par end."
		end
	end },

	{ id = "end_en_trop", find = function(_, lines)
		local o, e = 0, 0
		for _, l in ipairs(lines) do
			local c = _codeSeul(l)
			o += _compte(c, "%f[%w]function%f[%W]") + _compte(c, "%f[%w]then%f[%W]") + _compte(c, "%f[%w]do%f[%W]")
			e += _compte(c, "%f[%w]end%f[%W]")
			o -= _compte(c, "%f[%w]elseif%f[%W]")
		end
		if e - o >= 2 then
			return "Tu as " .. (e - o) .. " « end » de trop : un bloc est fermé deux fois."
		end
	end },

	{ id = "if_egal_simple", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("%f[%w]if%f[%W].*[^=<>~]=[^=]") and not c:match("==") then
				return "Dans un `if`, on compare avec `==` (deux signes égal). Un seul `=` sert à donner une valeur.", i
			end
		end
	end },

	{ id = "different_luau", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("!=") then
				return "En Luau, « différent de » s'écrit `~=` et non `!=`.", i
			end
		end
	end },

	{ id = "then_manquant", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if (c:match("^%s*if%f[%W]") or c:match("^%s*elseif%f[%W]"))
				and not c:match("%f[%w]then%f[%W]") and not c:match("[,%(%{]%s*$") then
				return "Un `if` doit se terminer par `then`.", i
			end
		end
	end },

	{ id = "do_manquant", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if (c:match("^%s*for%f[%W]") or c:match("^%s*while%f[%W]"))
				and not c:match("%f[%w]do%f[%W]") and not c:match("[,%(%{]%s*$") then
				return "Une boucle `for` ou `while` doit se terminer par `do`.", i
			end
		end
	end },

	{ id = "commentaire_slash", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = l:gsub('"[^"]*"', '""'):gsub("'[^']*'", "''")
			if c:match("^%s*//") or c:match("%s//") then
				if not l:match("https?:") then
					return "En Luau, un commentaire commence par `--` et non par `//`.", i
				end
			end
		end
	end },

	{ id = "accolades_js", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("%f[%w]if%s*%b()%s*{") or c:match("%f[%w]for%s*%(.*;.*;") then
				return "Luau n'utilise pas les accolades `{}` pour les blocs : c'est `then ... end` et `do ... end`.", i
			end
		end
	end },

	{ id = "repeat_sans_until", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%f[%w]repeat%f[%W]") then
				if not _joint(lines, i, i + 20):match("%f[%w]until%f[%W]") then
					return "Un bloc `repeat` doit se terminer par `until <condition>`.", i
				end
			end
		end
	end },

	-- ═══ BOUCLES & PERFORMANCE ═══
	{ id = "while_sans_wait", find = function(_, lines)
		local prof, debut, aWait = 0, nil, false
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if prof == 0 and c:match("%f[%w]while%f[%W].*%f[%w]do%f[%W]") then
				prof, debut, aWait = 1, i, false
			elseif prof > 0 then
				if c:match("task%.wait") or c:match("%f[%w]wait%f[%W]")
					or c:match("%f[%w]break%f[%W]") or c:match("%f[%w]return%f[%W]")
					or c:match("RunService") then
					aWait = true
				end
				prof += _compte(c, "%f[%w]then%f[%W]") + _compte(c, "%f[%w]do%f[%W]") + _compte(c, "%f[%w]function%f[%W]")
				prof -= _compte(c, "%f[%w]end%f[%W]")
				if prof <= 0 then
					if not aWait and debut then
						return "Ta boucle `while` n'a pas de `task.wait()` : elle tournera sans jamais rendre la main, et Studio se figera.", debut
					end
					prof, debut = 0, nil
				end
			end
		end
		if prof > 0 and not aWait and debut then
			return "Ta boucle `while` n'a pas de `task.wait()` : elle tournera sans jamais rendre la main, et Studio se figera.", debut
		end
	end },

	{ id = "wait_obsolete", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("[^%.%w]wait%s*%(") and not c:match("task%.wait") then
				return "`wait()` est déprécié : utilise `task.wait()`, plus précis et recommandé par Roblox.", i
			end
		end
	end },

	{ id = "spawn_obsolete", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("[^%.%w]spawn%s*%(") and not c:match("task%.spawn") then
				return "`spawn()` est déprécié : utilise `task.spawn()`.", i
			end
		end
	end },

	{ id = "delay_obsolete", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("[^%.%w]delay%s*%(") and not c:match("task%.delay") then
				return "`delay()` est déprécié : utilise `task.delay()`.", i
			end
		end
	end },

	{ id = "getchildren_boucle", find = function(_, lines)
		local prof = 0
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("%f[%w]while%f[%W].*%f[%w]do%f[%W]") or c:match("%f[%w]for%f[%W].*%f[%w]do%f[%W]") then
				prof += 1
			elseif prof > 0 and c:match("%f[%w]end%f[%W]") then
				prof -= 1
			end
			if prof > 0 and (c:match("GetDescendants%s*%(") ) then
				return "Tu parcours tous les descendants à chaque tour de boucle : stocke le résultat dans une variable AVANT la boucle.", i
			end
		end
	end },

	-- ═══ API ROBLOX ═══
	{ id = "getservice_manquant", find = function(_, lines)
		local services = { "Players", "ReplicatedStorage", "ServerStorage", "TweenService",
			"RunService", "DataStoreService", "UserInputService", "MarketplaceService" }
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			for _, s in ipairs(services) do
				if c:match("game%." .. s .. "%f[%W]") then
					return "Préfère `game:GetService(\"" .. s .. "\")` à `game." .. s ..
						"` : c'est la méthode officielle, et elle fonctionne même si le service n'est pas encore chargé.", i
				end
			end
		end
	end },

	{ id = "waitforchild_manquant", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("workspace%.%w+%.%w+") and not c:match("WaitForChild") and not c:match("FindFirstChild") then
				return "Tu accèdes à un objet imbriqué sans attendre son chargement : `WaitForChild(\"Nom\")` évite un plantage si l'objet n'est pas encore là.", i
			end
		end
	end },

	{ id = "datastore_sans_pcall", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match(":SetAsync%s*%(") or c:match(":GetAsync%s*%(")
				or c:match(":UpdateAsync%s*%(") or c:match(":IncrementAsync%s*%(") then
				if not _joint(lines, i - 4, i):match("pcall") then
					return "Un appel DataStore peut échouer (réseau, quota) : entoure-le d'un `pcall`, sinon ton script s'arrête net.", i
				end
			end
		end
	end },

	{ id = "http_sans_pcall", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match(":GetAsync%s*%(") or c:match(":PostAsync%s*%(") or c:match(":RequestAsync%s*%(") then
				if c:match("Http") or _joint(lines, 1, i):match("HttpService") then
					if not _joint(lines, i - 4, i):match("pcall") then
						return "Un appel HTTP peut échouer : entoure-le d'un `pcall`, et vérifie que les requêtes HTTP sont activées dans les paramètres du jeu.", i
					end
				end
			end
		end
	end },

	{ id = "humanoid_direct", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("%.Parent%.Humanoid%f[%W]") and not c:match("FindFirstChild") then
				return "Tout ce qui touche une Part n'est pas un joueur : vérifie avec `FindFirstChild(\"Humanoid\")` avant d'y accéder.", i
			end
		end
	end },

	{ id = "remove_obsolete", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match(":Remove%s*%(%s*%)") then
				return "`:Remove()` est déprécié : utilise `:Destroy()`, qui libère vraiment la mémoire.", i
			end
		end
	end },

	{ id = "color_brickcolor", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%.Color%s*=%s*BrickColor") then
				return "`.Color` attend un `Color3`. Pour un BrickColor, écris `.BrickColor = BrickColor.new(...)`.", i
			end
		end
	end },

	{ id = "position_cframe", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%.Position%s*=%s*CFrame%.new") then
				return "`.Position` attend un `Vector3`. Pour un CFrame, utilise `.CFrame = CFrame.new(...)`.", i
			end
		end
	end },

	{ id = "vector3_deux_args", find = function(_, lines)
		for i, l in ipairs(lines) do
			local args = _codeSeul(l):match("Vector3%.new%s*%(([^%)]*)%)")
			if args and args:match("%S") then
				local n = 1
				for _ in args:gmatch(",") do n += 1 end
				if n == 2 then
					return "`Vector3.new` attend trois nombres (X, Y, Z) : avec deux, le Z vaudra 0.", i
				end
			end
		end
	end },

	{ id = "tween_sans_play", find = function(src)
		if src:match("TweenService") and src:match(":Create%s*%(") and not src:match(":Play%s*%(") then
			return "Tu crées un Tween mais tu ne l'exécutes jamais : il faut appeler `:Play()` dessus."
		end
	end },

	{ id = "instance_sans_parent", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("Instance%.new%s*%(") then
				if not _joint(lines, i, i + 8):match("%.Parent%s*=") then
					return "Un objet créé sans `.Parent` n'apparaît nulle part : donne-lui un parent pour le voir dans le jeu.", i
				end
			end
		end
	end },

	{ id = "leaderstats_nom", find = function(src)
		local nom = src:match('%.Name%s*=%s*"([%w_]*[Ll]eaderstats?[%w_]*)"')
		if nom and nom ~= "leaderstats" then
			return "Le dossier des statistiques doit s'appeler exactement `leaderstats` (tout en minuscules) : « " ..
				nom .. " » ne sera pas affiché par Roblox."
		end
	end },

	{ id = "character_nil", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("%.Character%.") then
				local ctx = _joint(lines, i - 3, i)
				if not (ctx:match("CharacterAdded") or ctx:match("WaitForChild") or ctx:match("%f[%w]if%f[%W]")) then
					return "`.Character` vaut nil tant que le joueur n'est pas apparu : passe par `CharacterAdded`, ou vérifie avant d'y accéder.", i
				end
			end
		end
	end },

	{ id = "achat_mauvais_prompt", find = function(src)
		if src:match("UserOwnsGamePassAsync") and src:match("PromptProductPurchase") then
			return "Tu mélanges Game Pass et Developer Product : un Game Pass s'achète avec `PromptGamePassPurchase`, un consommable avec `PromptProductPurchase`."
		end
	end },

	-- ═══ CLIENT / SERVEUR ═══
	{ id = "localplayer_serveur", find = function(_, lines, meta)
		if meta and meta.className == "LocalScript" then return end
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%.LocalPlayer%f[%W]") then
				return "`LocalPlayer` n'existe QUE dans un LocalScript. Dans un Script serveur, il vaut toujours nil.", i
			end
		end
	end },

	{ id = "userinput_serveur", find = function(_, lines, meta)
		if meta and meta.className == "LocalScript" then return end
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("UserInputService") or c:match("ContextActionService") then
				return "Les entrées clavier et souris ne se lisent que côté client : place ce code dans un LocalScript.", i
			end
		end
	end },

	{ id = "datastore_client", find = function(_, lines, meta)
		if not meta or meta.className ~= "LocalScript" then return end
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("DataStoreService") then
				return "Un DataStore ne s'utilise que côté serveur : depuis un LocalScript, l'accès est refusé.", i
			end
		end
	end },

	{ id = "fireserver_serveur", find = function(_, lines, meta)
		if meta and meta.className == "LocalScript" then return end
		for i, l in ipairs(lines) do
			if _codeSeul(l):match(":FireServer%s*%(") then
				return "`FireServer` s'appelle depuis le CLIENT. Côté serveur, c'est `FireClient(joueur, ...)`.", i
			end
		end
	end },

	{ id = "fireclient_client", find = function(_, lines, meta)
		if not meta or meta.className ~= "LocalScript" then return end
		for i, l in ipairs(lines) do
			if _codeSeul(l):match(":FireClient%s*%(") then
				return "`FireClient` s'appelle depuis le SERVEUR. Depuis le client, utilise `FireServer(...)`.", i
			end
		end
	end },

	{ id = "remote_sans_validation", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("OnServerEvent:Connect%s*%(%s*function%s*%(") then
				local suite = _joint(lines, i, i + 12)
				if not (suite:match("%f[%w]if%f[%W]") or suite:match("typeof") or suite:match("tonumber")) then
					return "Un client peut envoyer n'importe quoi dans un RemoteEvent : vérifie toujours les valeurs reçues côté serveur avant de les utiliser.", i
				end
			end
		end
	end },

	-- ═══ SÉCURITÉ ═══
	{ id = "loadstring", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%f[%w]loadstring%s*%(") then
				return "`loadstring` est désactivé par défaut sur Roblox et présente un risque : évite-le.", i
			end
		end
	end },

	{ id = "require_id", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("require%s*%(%s*%d%d%d%d%d%d") then
				return "`require()` avec un ID d'asset charge du code que tu ne contrôles pas : c'est le vecteur classique des backdoors.", i
			end
		end
	end },

	-- ═══ VARIABLES & LOGIQUE ═══
	{ id = "variable_inutilisee", find = function(_, lines)
		for i, l in ipairs(lines) do
			local nom = l:match("^%s*local%s+([%w_]+)%s*=")
			if nom and #nom > 2 and nom ~= "_" then
				if not _joint(lines, i + 1, #lines):match("%f[%w]" .. nom .. "%f[%W]") then
					return "La variable « " .. nom .. " » est créée mais jamais utilisée ensuite. Faute de frappe sur son nom plus bas ?", i
				end
			end
		end
	end },

	{ id = "comparaison_bool", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match("==%s*true%f[%W]") or c:match("==%s*false%f[%W]") then
				return "`if x == true then` s'écrit plus simplement `if x then` (et `if not x then` pour l'inverse).", i
			end
		end
	end },

	{ id = "concat_plus", find = function(_, lines)
		for i, l in ipairs(lines) do
			local c = _codeSeul(l)
			if c:match('%+%s*""') or c:match('""%s*%+') then
				return "Pour coller du texte en Luau, on utilise `..` et non `+`.", i
			end
		end
	end },

	{ id = "index_zero", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%f[%w]for%s+%w+%s*=%s*0%s*,%s*#") then
				return "En Luau, les tables commencent à l'index 1 : la boucle s'écrit `for i = 1, #table do`.", i
			end
		end
	end },

	{ id = "length_js", find = function(_, lines)
		for i, l in ipairs(lines) do
			if _codeSeul(l):match("%.length%f[%W]") then
				return "En Luau, la longueur s'obtient avec `#` (ex : `#maTable`) ou `string.len()`, pas `.length`.", i
			end
		end
	end },

	{ id = "touched_sans_debounce", find = function(src)
		if src:match("%.Touched:Connect") then
			local s = src:lower()
			if not (s:match("debounce") or s:match("cooldown") or s:match("cantouch")
				or s:match("peuttoucher") or s:match("tick%s*%(") or s:match("os%.clock")) then
				return "`Touched` se déclenche plusieurs fois par contact : ajoute un anti-rebond (debounce), sinon ton code part en rafale."
			end
		end
	end },

	{ id = "anchored_oubli", find = function(src)
		if src:match('Instance%.new%s*%(%s*"Part"') and src:match("%.Position%s*=") and not src:match("Anchored") then
			return "Une Part créée sans `Anchored = true` tombera avec la gravité : ajoute-le si elle doit rester en place."
		end
	end },

	{ id = "randomseed_inutile", find = function(src)
		if src:match("math%.randomseed%s*%(%s*tick%s*%(%s*%)%s*%)") then
			return "`math.randomseed(tick())` n'est plus nécessaire : Roblox initialise déjà le générateur. Préfère `Random.new()`."
		end
	end },

	{ id = "print_nombreux", find = function(src)
		local n = 0
		for _ in src:gmatch("%f[%w]print%s*%(") do n += 1 end
		if n >= 12 then
			return n .. " appels à `print()` : pense à en retirer avant de publier, ils ralentissent le jeu."
		end
	end },
}

-- ── Analyse : renvoie (id, message, ligne) ou nil ────────────────────────
local function analyseScript(src, meta)
	if type(src) ~= "string" or #src < 20 then return nil end
	local lines = {}
	for l in (src .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = l end
	if #lines < 3 then return nil end
	for _, rule in ipairs(WATCH_RULES) do
		local ok, msg, ligne = pcall(rule.find, src, lines, meta)
		if ok and msg then return rule.id, msg, ligne end
	end
	return nil
end

-- ── Réponses locales aux questions fréquentes ────────────────────────────
-- Le plugin répond LUI-MÊME quand il sait. Pas d'appel réseau, pas de quota
-- consommé, réponse instantanée — et ça marche même quand l'IA est saturée.
-- `motifs` : mots recherchés dans la question (déjà en minuscules, sans
-- accents). TOUS les mots d'un groupe doivent être présents pour déclencher.
local QUICK_ANSWERS = {
	{
		id = "script_vs_localscript",
		motifs = { { "difference", "script" }, { "script", "localscript" }, { "quand", "localscript" } },
		titre = "Script ou LocalScript ?",
		corps = "**Script** tourne sur le SERVEUR : il gère ce qui doit être vrai pour tout le monde (points, sauvegarde, spawn des ennemis). Place-le dans `ServerScriptService`.\n\n" ..
			"**LocalScript** tourne sur la MACHINE DU JOUEUR : interface, clavier/souris, caméra. Place-le dans `StarterPlayerScripts`, `StarterGui` ou un Tool.\n\n" ..
			"Règle simple : si ça doit être partagé ou protégé de la triche → serveur. Si ça ne concerne qu'un joueur et son écran → client.",
		fiche = "remote-events",
	},
	{
		id = "remote_event",
		motifs = { { "remoteevent" }, { "remote", "event" }, { "client", "serveur", "communiquer" } },
		titre = "Faire communiquer client et serveur",
		corps = "Un **RemoteEvent** (dans `ReplicatedStorage`) est le pont entre les deux :\n\n" ..
			"• Du client vers le serveur : `remote:FireServer(donnees)`\n" ..
			"• Du serveur vers un client : `remote:FireClient(joueur, donnees)`\n" ..
			"• Côté serveur, on écoute : `remote.OnServerEvent:Connect(function(joueur, donnees) ... end)`\n\n" ..
			"⚠️ Le premier paramètre reçu côté serveur est TOUJOURS le joueur, ajouté par Roblox. Et vérifie toujours les données reçues : un client peut envoyer n'importe quoi.",
		fiche = "remote-events",
	},
	{
		id = "sauvegarder",
		motifs = { { "sauvegarder" }, { "datastore" }, { "sauvegarde", "donnees" } },
		titre = "Sauvegarder les données d'un joueur",
		corps = "Le **DataStore** conserve les données entre les sessions, côté serveur uniquement :\n\n" ..
			"```lua\nlocal DSS = game:GetService(\"DataStoreService\")\nlocal store = DSS:GetDataStore(\"Sauvegarde\")\n\n" ..
			"local ok, err = pcall(function()\n\tstore:SetAsync(joueur.UserId, valeur)\nend)\n```\n\n" ..
			"Trois règles : toujours dans un `pcall` (l'appel peut échouer), la clé est l'`UserId` (jamais le pseudo, il change), et sauvegarde sur `PlayerRemoving` plus périodiquement.",
		fiche = "datastore",
	},
	{
		id = "leaderstats",
		motifs = { { "leaderstats" }, { "classement", "joueur" }, { "afficher", "points" } },
		titre = "Afficher des points en jeu (leaderstats)",
		corps = "Roblox affiche automatiquement un dossier nommé **exactement** `leaderstats` placé dans le joueur :\n\n" ..
			"```lua\nlocal stats = Instance.new(\"Folder\")\nstats.Name = \"leaderstats\"\nstats.Parent = joueur\n\n" ..
			"local points = Instance.new(\"IntValue\")\npoints.Name = \"Points\"\npoints.Value = 0\npoints.Parent = stats\n```\n\n" ..
			"Le nom doit être en minuscules avec un « s ». `Leaderstats` ou `LeaderStats` ne fonctionnent pas.",
		fiche = "modulescripts-leaderstats",
	},
	{
		id = "touched",
		motifs = { { "touched" }, { "detecter", "touche" }, { "quand", "joueur", "touche" } },
		titre = "Détecter qu'un joueur touche un objet",
		corps = "```lua\nlocal piece = workspace:WaitForChild(\"Piece\")\nlocal debounce = false\n\n" ..
			"piece.Touched:Connect(function(hit)\n\tif debounce then return end\n" ..
			"\tlocal humanoid = hit.Parent:FindFirstChild(\"Humanoid\")\n\tif not humanoid then return end\n\n" ..
			"\tdebounce = true\n\t-- ton code ici\n\ttask.wait(1)\n\tdebounce = false\nend)\n```\n\n" ..
			"Deux pièges : `Touched` se déclenche en rafale (d'où le debounce), et tout ce qui touche n'est pas un joueur (d'où le test du Humanoid).",
		fiche = "evenements-instances",
	},
	{
		id = "waitforchild",
		motifs = { { "waitforchild" }, { "attempt", "index", "nil" }, { "objet", "introuvable" } },
		titre = "« attempt to index nil » — l'objet n'existe pas",
		corps = "Cette erreur veut dire que ce qui est AVANT le point n'a pas été trouvé.\n\n" ..
			"Trois causes, dans l'ordre de fréquence :\n" ..
			"1. **Orthographe** : les majuscules comptent. `Piece` ≠ `piece`.\n" ..
			"2. **Pas encore chargé** : utilise `workspace:WaitForChild(\"Nom\")` au lieu de `workspace.Nom`.\n" ..
			"3. **Mauvais endroit** : vérifie dans l'Explorer où se trouve réellement l'objet.\n\n" ..
			"Pour vérifier, affiche la valeur juste avant : `print(monObjet)`.",
		fiche = "evenements-instances",
	},
	{
		id = "tween",
		motifs = { { "tween" }, { "animer", "objet" }, { "deplacer", "doucement" } },
		titre = "Animer un objet en douceur (TweenService)",
		corps = "```lua\nlocal TS = game:GetService(\"TweenService\")\nlocal piece = workspace:WaitForChild(\"Plateforme\")\n\n" ..
			"local info = TweenInfo.new(2, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut)\n" ..
			"local objectif = { Position = piece.Position + Vector3.new(0, 10, 0) }\n\n" ..
			"local anim = TS:Create(piece, info, objectif)\nanim:Play()\n```\n\n" ..
			"N'oublie pas `:Play()` : sans lui, le Tween est créé mais ne démarre jamais.",
		fiche = "tweens-animations",
	},
	{
		id = "gui",
		motifs = { { "gui" }, { "interface" }, { "screengui" }, { "bouton", "ecran" } },
		titre = "Créer une interface (GUI)",
		corps = "Une interface vit dans un **ScreenGui** placé dans `StarterGui`. Elle est copiée dans chaque joueur à son arrivée.\n\n" ..
			"Hiérarchie type : `ScreenGui` → `Frame` → `TextButton` / `TextLabel`.\n\n" ..
			"Le code qui la pilote doit être un **LocalScript** (l'interface n'existe que côté client) :\n\n" ..
			"```lua\nlocal bouton = script.Parent\nbouton.MouseButton1Click:Connect(function()\n\tprint(\"cliqué !\")\nend)\n```",
		fiche = "gui-interface",
	},
	{
		id = "boucle",
		motifs = { { "boucle" }, { "while" }, { "repeter" }, { "for" } },
		titre = "Les boucles en Luau",
		corps = "**Nombre de tours connu** :\n```lua\nfor i = 1, 10 do\n\tprint(i)\nend\n```\n\n" ..
			"**Parcourir une table** :\n```lua\nfor index, valeur in ipairs(maTable) do\n\tprint(valeur)\nend\n```\n\n" ..
			"**Tant qu'une condition tient** :\n```lua\nwhile enCours do\n\ttask.wait(1)\nend\n```\n\n" ..
			"⚠️ Une boucle `while` SANS `task.wait()` fige Studio : elle ne rend jamais la main.",
		fiche = "conditions-boucles",
	},
	{
		id = "variable",
		-- « declarer » est volontairement absent : il capte aussi « déclarer
		-- une fonction », qui a sa propre fiche.
		motifs = { { "variable" }, { "variables" } },
		titre = "Les variables en Luau",
		corps = "```lua\nlocal nom = \"Roblox\"   -- texte\nlocal points = 100      -- nombre\n" ..
			"local actif = true      -- booléen\nlocal rien = nil        -- vide\n```\n\n" ..
			"Toujours `local` : sans lui, la variable devient globale, reste en mémoire et peut entrer en conflit avec un autre script.\n\n" ..
			"Luau est **sensible à la casse** : `Score` et `score` sont deux variables différentes.",
		fiche = "luau-bases",
	},
	{
		id = "fonction",
		motifs = { { "fonction" }, { "function" } },
		titre = "Les fonctions en Luau",
		corps = "```lua\nlocal function additionner(a, b)\n\treturn a + b\nend\n\nprint(additionner(2, 3))  -- 5\n```\n\n" ..
			"Une fonction doit être définie AVANT la ligne qui l'appelle. Elle peut renvoyer plusieurs valeurs : `return a, b`.",
		fiche = "fonctions-tables",
	},
	{
		id = "table",
		motifs = { { "table" }, { "tableau" }, { "liste" } },
		titre = "Les tables (listes) en Luau",
		corps = "```lua\nlocal fruits = { \"pomme\", \"poire\", \"cerise\" }\nprint(fruits[1])   -- pomme (on commence à 1 !)\n" ..
			"print(#fruits)     -- 3\n\ntable.insert(fruits, \"kiwi\")\ntable.remove(fruits, 1)\n```\n\n" ..
			"⚠️ Contrairement à la plupart des langages, les tables Luau **commencent à l'index 1**, pas 0.",
		fiche = "fonctions-tables",
	},
	{
		id = "anti_triche",
		motifs = { { "triche" }, { "cheat" }, { "securiser" }, { "exploit" } },
		titre = "Protéger son jeu de la triche",
		corps = "Une règle suffit à retenir : **le client ment**. Tout ce qui vient d'un LocalScript peut être falsifié.\n\n" ..
			"• Les décisions importantes (points, achats, dégâts) se prennent CÔTÉ SERVEUR.\n" ..
			"• Un RemoteEvent est une porte ouverte : valide TOUT ce qui arrive (type, bornes, droits).\n" ..
			"• Ne fais jamais confiance à une valeur envoyée par le client — recalcule-la.\n" ..
			"• `loadstring` et `require(id)` sont les portes d'entrée classiques des backdoors.",
		fiche = "securite-perf-archi",
	},
	{
		id = "publier",
		motifs = { { "publier" }, { "mettre", "en", "ligne" }, { "monetiser" }, { "gamepass" } },
		titre = "Publier et monétiser son jeu",
		corps = "**Publier** : Fichier → Publier sur Roblox. Puis dans les paramètres du jeu, passe-le en « Public ».\n\n" ..
			"**Game Pass** : achat unique et permanent (accès VIP, arme). Vérifie avec `UserOwnsGamePassAsync`, propose avec `PromptGamePassPurchase`.\n\n" ..
			"**Developer Product** : achat répétable (pièces, boost). Propose avec `PromptProductPurchase`, et traite dans `ProcessReceipt`.",
		fiche = "monetisation",
	},
	{
		id = "erreur_output",
		motifs = { { "erreur" }, { "output" }, { "marche", "pas" }, { "bug" } },
		titre = "Trouver pourquoi ça ne marche pas",
		corps = "1. Ouvre la **Sortie** (Vue → Sortie) : les erreurs s'y affichent en rouge, avec le numéro de ligne.\n" ..
			"2. Lis la PREMIÈRE erreur seulement : les suivantes en découlent souvent.\n" ..
			"3. Ajoute des `print()` avant la ligne qui plante pour voir ce que contiennent tes variables.\n\n" ..
			"Si l'erreur reste obscure, copie-la ici : je la traduis.",
		fiche = "studio-interface",
	},
}

-- Index des antisèches du site : Bloxi oriente vers la bonne fiche.
local CHEATSHEETS = {
	{ slug = "luau-bases", titre = "Luau — Les Bases", modules = "Modules 1–2" },
	{ slug = "studio-interface", titre = "Roblox Studio — Interface", modules = "Module 1" },
	{ slug = "conditions-boucles", titre = "Conditions & Boucles", modules = "Modules 3–4" },
	{ slug = "fonctions-tables", titre = "Fonctions & Tables", modules = "Modules 5–6" },
	{ slug = "evenements-instances", titre = "Événements & Instances", modules = "Modules 7–8" },
	{ slug = "tweens-animations", titre = "TweenService & Animations", modules = "Module 9" },
	{ slug = "remote-events", titre = "RemoteEvents — Client ↔ Serveur", modules = "Module 10" },
	{ slug = "gui-interface", titre = "GUI — Interface Joueur", modules = "Modules 11–12" },
	{ slug = "datastore", titre = "DataStore — Sauvegarder les données", modules = "Module 13" },
	{ slug = "monetisation", titre = "Publier & Monétiser", modules = "Module 14" },
	{ slug = "modulescripts-leaderstats", titre = "ModuleScripts & Leaderstats", modules = "Modules 15–16" },
	{ slug = "tools-son-lumiere", titre = "Tools · Son · Lumière & FX", modules = "Modules 17–19" },
	{ slug = "gameloop-pathfinding", titre = "Game Loop & Pathfinding IA", modules = "Modules 20–21" },
	{ slug = "raycasting-combat", titre = "Raycasting · Animations · Combat", modules = "Modules 22–24" },
	{ slug = "securite-perf-archi", titre = "Sécurité · Performance · Architecture", modules = "Modules 25–27" },
	{ slug = "build-bases", titre = "Build — Les Bases", modules = "Build 1–6" },
	{ slug = "build-avance", titre = "Build — Avancé", modules = "Build 7–13" },
}

-- Normalise une question : minuscules, sans accents, sans ponctuation.
local _ACCENTS = {
	["à"]="a",["â"]="a",["ä"]="a",["é"]="e",["è"]="e",["ê"]="e",["ë"]="e",
	["î"]="i",["ï"]="i",["ô"]="o",["ö"]="o",["ù"]="u",["û"]="u",["ü"]="u",["ç"]="c",
}
local function _normalise(txt)
	local s = (txt or ""):lower()
	for k, v in pairs(_ACCENTS) do s = s:gsub(k, v) end
	return " " .. s:gsub("[^%w%s]", " "):gsub("%s+", " ") .. " "
end

-- Cherche une réponse locale. Renvoie l'entrée, ou nil si l'IA doit répondre.
local function findQuickAnswer(question)
	local q = _normalise(question)
	-- Une question longue est presque toujours spécifique au projet de
	-- l'élève : une fiche générique passerait à côté, mieux vaut l'IA.
	local mots = 0
	for _ in q:gmatch("%S+") do mots += 1 end
	if mots > 14 then return nil end

	for _, entry in ipairs(QUICK_ANSWERS) do
		for _, groupe in ipairs(entry.motifs) do
			local tous = true
			for _, mot in ipairs(groupe) do
				if not q:match("%f[%w]" .. mot .. "%f[%W]") then tous = false break end
			end
			if tous then return entry end
		end
	end
	return nil
end

local function findCheatsheet(slug)
	for _, c in ipairs(CHEATSHEETS) do
		if c.slug == slug then return c end
	end
	return nil
end


-- Analyse une ligne d'erreur brute. Retourne (titre, conseil, ligne) ou nil.
local function explainError(raw)
	if type(raw) ~= "string" or raw == "" then return nil end

	-- Numéro de ligne : Roblox préfixe « Script:12: message »
	local lineNo = raw:match("^[^:]*:(%d+):") or raw:match(":(%d+):")

	for _, rule in ipairs(ERROR_RULES) do
		local capture = raw:match(rule.match)
		if capture then
			local what = rule.what
			-- Les règles à capture insèrent le nom concerné dans le message
			if what:find("%%s") and type(capture) == "string" then
				what = what:format(capture)
			end
			return what, rule.fix, lineNo and tonumber(lineNo) or nil
		end
	end

	-- Erreur non répertoriée : on rend au moins le message brut lisible,
	-- débarrassé du préfixe technique.
	local cleaned = raw:gsub("^%s*[%w_%.]+:%d+:%s*", "")
	if #cleaned > 0 and #cleaned < 240 then
		return cleaned, "Relis la ligne indiquée dans ton script.", lineNo and tonumber(lineNo) or nil
	end
	return nil
end

-- Récupère le code le plus à jour (document ouvert prioritaire sur .Source)
local function diffColor(d)
	if d == "facile" then return C.green
	elseif d == "moyen" then return C.orange
	else return C.red end
end

-- ── low-level constructors ──
local function corner(inst, r)
	local c = Instance.new("UICorner"); c.CornerRadius = UDim.new(0, r or R.sm); c.Parent = inst; return c
end

-- Dégradé linéaire (équivalent des --gradient-* du site). `rot` en degrés :
-- 90 = vertical, 45 ≈ le "135deg" des dégradés CSS du site.
local function gradient(inst, from, to, rot, transparency)
	local g = Instance.new("UIGradient")
	g.Color = ColorSequence.new(from, to)
	g.Rotation = rot or 45
	if transparency then
		g.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, transparency[1] or 0),
			NumberSequenceKeypoint.new(1, transparency[2] or 0),
		})
	end
	g.Parent = inst
	return g
end
local function stroke(inst, color, thick)
	local s = Instance.new("UIStroke"); s.Color = color or C.border; s.Thickness = thick or 1
	s.ApplyStrokeMode = Enum.ApplyStrokeMode.Border; s.Parent = inst; return s
end
local function pad(inst, l, t, r, b)
	local p = Instance.new("UIPadding")
	p.PaddingLeft = UDim.new(0, l or 0); p.PaddingTop = UDim.new(0, t or 0)
	p.PaddingRight = UDim.new(0, r or l or 0); p.PaddingBottom = UDim.new(0, b or t or 0)
	p.Parent = inst; return p
end
local function vlist(inst, gap, sort)
	local l = Instance.new("UIListLayout")
	l.FillDirection = Enum.FillDirection.Vertical
	l.SortOrder = sort or Enum.SortOrder.LayoutOrder
	l.Padding = UDim.new(0, gap or 0); l.Parent = inst; return l
end
local function hlist(inst, gap, valign)
	local l = Instance.new("UIListLayout")
	l.FillDirection = Enum.FillDirection.Horizontal
	l.VerticalAlignment = valign or Enum.VerticalAlignment.Center
	l.SortOrder = Enum.SortOrder.LayoutOrder
	l.Padding = UDim.new(0, gap or 0); l.Parent = inst; return l
end

-- ── frame ──
local function frame(parent, props)
	props = props or {}
	local f = Instance.new("Frame")
	f.BackgroundColor3 = props.bg or C.card
	f.BackgroundTransparency = props.transparent and 1 or (props.alpha or 0)
	f.BorderSizePixel = 0
	f.Size = props.size or UDim2.new(1, 0, 0, 0)
	f.AutomaticSize = props.autoY == false and Enum.AutomaticSize.None or Enum.AutomaticSize.Y
	f.LayoutOrder = props.order or 0
	f.Parent = parent
	if props.radius then corner(f, props.radius) end
	if props.stroke then stroke(f, props.stroke, props.strokeThick) end
	if props.gradientTo then gradient(f, props.bg or C.card, props.gradientTo, props.gradientRot) end
	return f
end

-- Carte standard du site : surface + rayon lg + bordure discrète.
-- `accent` ajoute un liseré coloré à gauche (comme les cartes de track web).
--
-- ATTENTION : `accent` est INCOMPATIBLE avec un UIListLayout posé sur la
-- carte. Le liseré fait 1,0 en hauteur ; un layout le compte comme un enfant
-- et lui réserve une ligne pleine hauteur, ce qui repousse tout le contenu
-- en dessous. Pour une carte à vlist, utilise `stroke = <couleur>` à la place.
local function card(parent, props)
	props = props or {}
	local c = frame(parent, {
		bg = props.bg or C.card,
		radius = props.radius or R.lg,
		stroke = props.stroke ~= false and (props.stroke or C.border) or nil,
		order = props.order,
		autoY = props.autoY,
		size = props.size,
	})
	if props.accent then
		local bar = Instance.new("Frame")
		bar.BackgroundColor3 = props.accent
		bar.BorderSizePixel = 0
		bar.Size = UDim2.new(0, 3, 1, 0)
		bar.Position = UDim2.new(0, 0, 0, 0)
		bar.ZIndex = 2
		bar.Parent = c
		corner(bar, 2)
	end
	return c
end

-- ── text ──
local function label(parent, props)
	local t = Instance.new("TextLabel")
	t.BackgroundTransparency = 1
	t.Font = props.bold and Enum.Font.GothamBold or (props.medium and Enum.Font.GothamMedium or Enum.Font.Gotham)
	t.TextSize = props.ts or 13
	t.TextColor3 = props.color or C.text
	t.TextXAlignment = props.align or Enum.TextXAlignment.Left
	t.TextYAlignment = Enum.TextYAlignment.Top
	t.LineHeight = props.lh or 1.18 -- respiration verticale (le site est à ~1.5)
	t.Text = props.text or ""
	t.LayoutOrder = props.order or 0
	t.RichText = props.rich == true
	t.AutoLocalize = false -- jamais de traduction auto sur nos libellés
	if props.truncate then
		t.TextWrapped = false
		t.TextTruncate = Enum.TextTruncate.AtEnd
		t.Size = props.size or UDim2.new(1, 0, 0, props.ts and props.ts + 6 or 18)
	else
		t.TextWrapped = true
		t.AutomaticSize = Enum.AutomaticSize.Y
		t.Size = props.size or UDim2.new(1, 0, 0, 0)
	end
	t.Parent = parent
	return t
end

-- ── button ──
-- `variant` reprend les styles de boutons du site :
--   primary   : fond accent plein (CTA)
--   secondary : surface + bordure
--   ghost     : transparent, texte accent
--   soft      : fond accent très clair, texte accent
--   success / danger : équivalents sémantiques
-- Hover animé + léger enfoncement au clic (feedback tactile).
local VARIANTS
local function button(parent, props)
	-- Résolution du variant → bg / texte / bordure / hover
	if props.variant then
		local v = VARIANTS[props.variant]
		if v then
			props.bg = props.bg or v.bg()
			props.tc = props.tc or v.tc()
			props.hover = props.hover or v.hover()
			if props.stroke == nil and v.stroke then props.stroke = v.stroke() end
			if v.alpha and props.alpha == nil then props.alpha = v.alpha end
		end
	end

	local b = Instance.new("TextButton")
	b.AutoButtonColor = false
	b.BackgroundColor3 = props.bg or C.accent
	b.BackgroundTransparency = props.alpha or 0
	b.BorderSizePixel = 0
	b.Font = props.font or Enum.Font.GothamBold
	b.TextSize = props.ts or 12
	b.TextColor3 = props.tc or C.onAccent
	b.Text = props.text or ""
	b.TextWrapped = props.autoY == true
	b.LayoutOrder = props.order or 0
	b.AutoLocalize = false
	if props.autoY then
		b.AutomaticSize = Enum.AutomaticSize.Y
		b.Size = props.size or UDim2.new(1, 0, 0, 0)
		pad(b, 12, 10, 12, 10)
	else
		b.Size = props.size or UDim2.new(1, 0, 0, 38)
	end
	b.Parent = parent
	corner(b, props.radius or R.md)
	if props.stroke then stroke(b, props.stroke, props.strokeThick or 1) end
	if props.gradientTo then gradient(b, props.bg or C.accent, props.gradientTo, props.gradientRot or 0) end

	local base = props.bg or C.accent
	local hov = props.hover or shade(base, currentTheme == "dark" and 0.06 or -0.05)
	local hovering = false
	b.MouseEnter:Connect(function()
		hovering = true
		TweenService:Create(b, TweenInfo.new(0.12), { BackgroundColor3 = hov }):Play()
	end)
	b.MouseLeave:Connect(function()
		hovering = false
		TweenService:Create(b, TweenInfo.new(0.12), { BackgroundColor3 = base }):Play()
	end)
	-- Feedback au clic : assombrit brièvement (le site fait un translateY,
	-- impossible ici sans casser l'UIListLayout — on joue sur la couleur).
	b.MouseButton1Down:Connect(function()
		TweenService:Create(b, TweenInfo.new(0.06), { BackgroundColor3 = shade(base, -0.08) }):Play()
	end)
	b.MouseButton1Up:Connect(function()
		TweenService:Create(b, TweenInfo.new(0.1), { BackgroundColor3 = hovering and hov or base }):Play()
	end)
	if props.onClick then b.MouseButton1Click:Connect(props.onClick) end
	return b
end

VARIANTS = {
	primary   = { bg = function() return C.accent end,   tc = function() return C.onAccent end, hover = function() return C.accentDark end },
	secondary = { bg = function() return C.card end,     tc = function() return C.text end,     hover = function() return C.cardHover end, stroke = function() return C.border end },
	ghost     = { bg = function() return C.card end,     tc = function() return C.accent end,   hover = function() return C.cardHover end, stroke = function() return C.border end, alpha = 1 },
	soft      = { bg = function() return C.accentBg end, tc = function() return C.accent end,   hover = function() return shade(C.accentBg, currentTheme == "dark" and 0.04 or -0.03) end },
	success   = { bg = function() return C.green end,    tc = function() return C.white end,    hover = function() return shade(C.green, -0.06) end },
	danger    = { bg = function() return C.redBg end,    tc = function() return C.red end,      hover = function() return shade(C.redBg, currentTheme == "dark" and 0.04 or -0.03) end, stroke = function() return C.red end },
}

-- ── pill (badge auto-largeur) ──
local function pill(parent, txt, bg, fg, order, opts)
	opts = opts or {}
	local p = Instance.new("Frame")
	p.BackgroundColor3 = bg
	p.BackgroundTransparency = opts.alpha or 0
	p.BorderSizePixel = 0
	p.AutomaticSize = Enum.AutomaticSize.XY
	p.Size = UDim2.new(0, 0, 0, 0)
	p.LayoutOrder = order or 0
	p.Parent = parent
	corner(p, R.pill)
	pad(p, 9, 4, 9, 4)
	if opts.stroke then stroke(p, opts.stroke, 1) end
	local t = Instance.new("TextLabel")
	t.BackgroundTransparency = 1
	t.AutomaticSize = Enum.AutomaticSize.XY
	t.Size = UDim2.new(0, 0, 0, 0)
	t.Font = Enum.Font.GothamBold
	t.TextSize = opts.ts or 10
	t.TextColor3 = fg
	t.Text = txt
	t.AutoLocalize = false
	t.Parent = p
	return p
end

-- ── chip (stat compacte de l'en-tête, cf. .hero-chip du dashboard) ──
-- `icon` est optionnel : sans lui, on affiche une pastille de couleur, qui
-- rend partout là où un emoji tomberait en carré vide.
local function chip(parent, icon, value, lbl, color, order)
	local c = frame(parent, { bg = C.bgSurface, autoY = false, size = UDim2.new(0, 0, 1, 0), radius = R.pill, order = order, stroke = C.border })
	c.AutomaticSize = Enum.AutomaticSize.X
	pad(c, 9, 0, 11, 0)
	local l = hlist(c, 6)
	l.VerticalAlignment = Enum.VerticalAlignment.Center
	if icon then
		local ic = label(c, { text = icon, ts = 11, color = color, size = UDim2.new(0, 13, 1, 0), order = 1 })
		ic.TextWrapped = false; ic.TextYAlignment = Enum.TextYAlignment.Center; ic.TextXAlignment = Enum.TextXAlignment.Center
	else
		local dot = Instance.new("Frame")
		dot.BackgroundColor3 = color
		dot.BorderSizePixel = 0
		dot.AnchorPoint = Vector2.new(0, 0.5)
		dot.Size = UDim2.new(0, 6, 0, 6)
		dot.LayoutOrder = 1
		dot.Parent = c
		corner(dot, R.pill)
	end
	local v = Instance.new("TextLabel")
	v.BackgroundTransparency = 1
	v.AutomaticSize = Enum.AutomaticSize.X
	v.Size = UDim2.new(0, 0, 1, 0)
	v.Font = Enum.Font.GothamBold
	v.TextSize = 11
	v.TextColor3 = C.textBright
	v.Text = tostring(value)
	v.LayoutOrder = 2
	v.AutoLocalize = false
	v.Parent = c
	if lbl and lbl ~= "" then
		local s = Instance.new("TextLabel")
		s.BackgroundTransparency = 1
		s.AutomaticSize = Enum.AutomaticSize.X
		s.Size = UDim2.new(0, 0, 1, 0)
		s.Font = Enum.Font.GothamMedium
		s.TextSize = 10
		s.TextColor3 = C.textMuted
		s.Text = lbl
		s.LayoutOrder = 3
		s.AutoLocalize = false
		s.Parent = c
	end
	return c
end

-- ── pastille d'icône carrée (cartes de module / feature) ──
local function iconBadge(parent, glyph, fg, order, size)
	size = size or 34
	local box = frame(parent, {
		bg = C.card:Lerp(fg, currentTheme == "dark" and 0.22 or 0.12),
		autoY = false, size = UDim2.new(0, size, 0, size),
		radius = R.md, order = order, stroke = fg, strokeThick = 1,
	})
	-- Les sigles de 2-3 caractères doivent rétrécir pour tenir dans la pastille
	local ratio = #glyph >= 3 and 0.30 or (#glyph == 2 and 0.36 or 0.46)
	local g = label(box, { text = glyph, ts = math.floor(size * ratio), bold = true, color = fg, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
	g.TextYAlignment = Enum.TextYAlignment.Center
	g.TextWrapped = false
	return box, g
end

-- ── coche de réussite en badge d'angle ──
-- Se pose sur le coin d'une pastille numérotée : signale l'état « validé »
-- sans masquer le numéro du module, qui reste le repère de position.
local function tickBadge(parent)
	local b = Instance.new("Frame")
	b.BackgroundColor3 = C.green
	b.BorderSizePixel = 0
	b.AnchorPoint = Vector2.new(0.5, 0.5)
	b.Position = UDim2.new(1, 0, 0, 0)
	b.Size = UDim2.new(0, 14, 0, 14)
	b.ZIndex = 4
	b.Parent = parent
	corner(b, R.pill)
	local t = Instance.new("TextLabel")
	t.BackgroundTransparency = 1
	t.Size = UDim2.new(1, 0, 1, 0)
	t.Font = Enum.Font.GothamBold
	t.TextSize = 9
	t.TextColor3 = C.white
	t.Text = "✓"
	t.ZIndex = 5
	t.AutoLocalize = false
	t.Parent = b
	return b
end

-- ── cadenas en badge d'angle ──
-- Pendant de tickBadge pour l'état verrouillé : le numéro du module reste
-- lisible dans la pastille, le cadenas se pose sur son coin.
-- `tint` colore le badge (doré pour un module premium) ; par défaut, neutre.
local function lockBadge(parent, tint)
	local bg = tint or (currentTheme == "dark" and C.elevated or C.textMuted)
	local fg = tint and C.white or (currentTheme == "dark" and C.textSec or C.white)

	local b = Instance.new("Frame")
	b.BackgroundColor3 = bg
	b.BorderSizePixel = 0
	b.AnchorPoint = Vector2.new(0.5, 0.5)
	b.Position = UDim2.new(1, 0, 0, 0)
	b.Size = UDim2.new(0, 15, 0, 15)
	b.ZIndex = 4
	b.Parent = parent
	corner(b, R.pill)
	stroke(b, tint or (currentTheme == "dark" and C.border or C.textMuted), 1)

	-- Mini-cadenas : anse en arc + corps plein, à l'échelle du badge
	local body = Instance.new("Frame")
	body.BackgroundColor3 = fg
	body.BorderSizePixel = 0
	body.AnchorPoint = Vector2.new(0.5, 0.5)
	body.Position = UDim2.new(0.5, 0, 0.5, 1.5)
	body.Size = UDim2.new(0, 7, 0, 5)
	body.ZIndex = 6
	body.Parent = b
	corner(body, 1)

	local shackle = Instance.new("Frame")
	shackle.BackgroundTransparency = 1
	shackle.AnchorPoint = Vector2.new(0.5, 0.5)
	shackle.Position = UDim2.new(0.5, 0, 0.5, -2)
	shackle.Size = UDim2.new(0, 5, 0, 5)
	shackle.ZIndex = 5
	shackle.Parent = b
	corner(shackle, R.pill)
	local ss = Instance.new("UIStroke")
	ss.Color = fg
	ss.Thickness = 1.2
	ss.Parent = shackle

	return b
end

-- ── état vide illustré (pastille + titre + explication) ──
local function emptyState(parent, glyph, title, desc, color, order)
	local box = frame(parent, { transparent = true, order = order or 50 })
	pad(box, 6, 34, 6, 10)
	local l = vlist(box, 9)
	l.HorizontalAlignment = Enum.HorizontalAlignment.Center
	local tint = color or C.textMuted
	local iconBox = frame(box, {
		bg = C.card:Lerp(tint, currentTheme == "dark" and 0.18 or 0.1),
		autoY = false, size = UDim2.new(0, 48, 0, 48), radius = R.lg, order = 1,
		stroke = tint, strokeThick = 1,
	})
	local g = label(iconBox, {
		text = glyph, ts = #glyph >= 3 and 15 or (#glyph == 2 and 18 or 22),
		bold = true, color = tint, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
	})
	g.TextYAlignment = Enum.TextYAlignment.Center
	g.TextWrapped = false
	label(box, { text = title, ts = 13, bold = true, color = C.textBright, align = Enum.TextXAlignment.Center, order = 2 })
	if desc and desc ~= "" then
		label(box, { text = desc, ts = 11, color = C.textMuted, align = Enum.TextXAlignment.Center, order = 3 })
	end
	return box
end

-- ── note de bas de liste (renvoi vers le site) ──
local function footnote(parent, txt, order)
	local n = frame(parent, { bg = C.bgSurface, radius = R.md, order = order or 900, stroke = C.borderSoft })
	pad(n, 12, 10, 12, 10)
	label(n, { text = txt, ts = 11, color = C.textMuted, lh = 1.25 })
	return n
end

-- ── titre de section (petites capitales, comme les en-têtes du dashboard) ──
local function sectionTitle(parent, txt, order, trailing)
	local row = frame(parent, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 22), order = order })
	pad(row, 2, 6, 2, 0)
	local l = hlist(row, 6); l.VerticalAlignment = Enum.VerticalAlignment.Center
	local t = label(row, { text = txt, ts = 10, bold = true, color = C.textMuted, size = UDim2.new(1, trailing and -70 or 0, 1, 0), truncate = true, order = 1 })
	t.TextYAlignment = Enum.TextYAlignment.Center
	if trailing then
		local tr = label(row, { text = trailing, ts = 10, bold = true, color = C.textMuted, align = Enum.TextXAlignment.Right, size = UDim2.new(0, 64, 1, 0), order = 2 })
		tr.TextYAlignment = Enum.TextYAlignment.Center
		tr.TextWrapped = false
	end
	return row
end

-- ── barre de progression animée (track + fill) ──
local function progressBar(parent, ratio, color, order, height)
	local track = frame(parent, {
		bg = currentTheme == "dark" and C.elevated or C.cardHover,
		autoY = false, size = UDim2.new(1, 0, 0, height or 7), radius = R.pill, order = order,
	})
	local fill = frame(track, { bg = color or C.accent, autoY = false, size = UDim2.new(0, 0, 1, 0), radius = R.pill })
	gradient(fill, color or C.accent, shade(color or C.accent, 0.12), 0)
	TweenService:Create(fill, TweenInfo.new(0.55, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
		{ Size = UDim2.new(math.clamp(ratio or 0, 0, 1), 0, 1, 0) }):Play()
	return track, fill
end

-- ── jauge circulaire segmentée ──
-- 12 tirets sur le pourtour, allumés au prorata. Roblox n'a pas d'arc natif :
-- un demi-disque pivoté + ClipsDescendants ne découpe pas correctement une
-- fois le parent tourné. Des segments discrets se calculent exactement.
local function ringGauge(parent, ratio, color, order, size)
	size = size or 40
	local box = frame(parent, { transparent = true, autoY = false, size = UDim2.new(0, size, 0, size), order = order })
	local frac = math.clamp(ratio or 0, 0, 1)
	local N = 12
	-- Au moins un segment dès qu'il y a du progrès : arrondir 4 % à zéro
	-- ferait mentir la jauge.
	local lit = (frac > 0) and math.max(1, math.floor(frac * N + 0.5)) or 0
	local radius = size * 0.4125
	for i = 1, N do
		local ang = math.rad((i - 1) * (360 / N) - 90) -- départ en haut
		local seg = Instance.new("Frame")
		seg.BackgroundColor3 = color
		seg.BackgroundTransparency = (i <= lit) and 0 or 0.82
		seg.BorderSizePixel = 0
		seg.AnchorPoint = Vector2.new(0.5, 0.5)
		seg.Position = UDim2.new(0.5, math.cos(ang) * radius, 0.5, math.sin(ang) * radius)
		seg.Size = UDim2.new(0, 4, 0, 4)
		seg.Rotation = math.deg(ang) + 90 -- tirets orientés vers le centre
		seg.Parent = box
		corner(seg, R.pill)
	end
	local txt = label(box, {
		text = math.floor(frac * 100 + 0.5) .. "%", ts = size * 0.275, bold = true, color = color,
		align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
	})
	txt.TextYAlignment = Enum.TextYAlignment.Center
	txt.TextWrapped = false
	return box
end

-- ── carte « état + action » ──
-- Le motif partagé par l'accueil et les deux sous-onglets d'Apprendre :
-- un bandeau (jauge + chiffres + barre) et, cousue dessous par un filet, une
-- zone d'action cliquable. Une seule carte, parce que l'état et l'action qui
-- en découle appartiennent au même geste.
--
-- IMPORTANT : aucune décoration ne doit être enfant de la zone d'action — son
-- UIListLayout positionne TOUS les enfants Frame, et une décoration prendrait
-- une colonne du flux en poussant le contenu hors du cadre. Le filet est donc
-- posé sur la carte, ordonné entre le bandeau et la zone.
--
-- opts = {
--   order, color, ratio,
--   title, subtitle,           -- bandeau
--   action = {                 -- zone d'action (optionnelle)
--     kicker, title, subtitle, badge = {text=…} | {play=true},
--     onClick,
--   },
-- }
local function statusCard(parent, opts)
	local col = opts.color or C.accent
	local c = card(parent, { radius = R.lg, order = opts.order })
	-- PAS de ClipsDescendants : il désactive AutomaticSize.Y et la carte
	-- rognerait la zone d'action au lieu de grandir.
	vlist(c, 0)

	local top = frame(c, { transparent = true, order = 1 })
	pad(top, 16, 15, 16, 14)
	vlist(top, 12)

	local row = frame(top, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 40), order = 1 })
	hlist(row, 13, Enum.VerticalAlignment.Center)
	ringGauge(row, opts.ratio, col, 1, 40)

	local info = frame(row, { transparent = true, autoY = false, size = UDim2.new(1, -53, 1, 0), order = 2 })
	local iv = vlist(info, 3); iv.VerticalAlignment = Enum.VerticalAlignment.Center
	label(info, { text = opts.title or "", ts = 13, bold = true, color = C.textBright, truncate = true, order = 1 })
	label(info, { text = opts.subtitle or "", ts = 11, color = C.textMuted, truncate = true, order = 2 })

	progressBar(top, opts.ratio, col, 2, 6)

	local a = opts.action
	if not a then return c end

	-- Filet : enfant de la CARTE (voir la note plus haut).
	local sep = frame(c, { bg = C.border, alpha = 0.45, autoY = false, size = UDim2.new(1, 0, 0, 1), order = 2 })

	local btn = Instance.new("TextButton")
	btn.Text = ""
	btn.AutoButtonColor = false
	btn.BackgroundColor3 = currentTheme == "dark" and C.bgSurface or C.accentBg
	btn.BorderSizePixel = 0
	btn.AutomaticSize = Enum.AutomaticSize.None
	btn.Size = UDim2.new(1, 0, 0, 54)
	btn.LayoutOrder = 3
	btn.Parent = c
	pad(btn, 16, 0, 14, 0)
	hlist(btn, 11, Enum.VerticalAlignment.Center)

	local hoverBg = currentTheme == "dark" and C.elevated or C.card
	local restBg = btn.BackgroundColor3
	btn.MouseEnter:Connect(function()
		TweenService:Create(btn, TweenInfo.new(0.12), { BackgroundColor3 = hoverBg }):Play()
	end)
	btn.MouseLeave:Connect(function()
		TweenService:Create(btn, TweenInfo.new(0.12), { BackgroundColor3 = restBg }):Play()
	end)
	if a.onClick then btn.MouseButton1Click:Connect(a.onClick) end

	local badge = frame(btn, { bg = col, autoY = false, size = UDim2.new(0, 30, 0, 30), radius = R.pill, order = 1 })
	gradient(badge, col, shade(col, -0.16), 135)
	if a.badge and a.badge.play then
		-- Triangle approché par deux barres en chevron : Roblox n'a pas de
		-- forme triangulaire native.
		local tri = frame(badge, { transparent = true, autoY = false, size = UDim2.new(0, 12, 0, 12) })
		tri.AnchorPoint = Vector2.new(0.5, 0.5)
		tri.Position = UDim2.new(0.5, 0, 0.5, 0)
		local t1 = Instance.new("Frame")
		t1.BackgroundColor3 = C.onAccent
		t1.BorderSizePixel = 0
		t1.AnchorPoint = Vector2.new(0.5, 0.5)
		t1.Position = UDim2.new(0.5, 1, 0.5, -2.6)
		t1.Size = UDim2.new(0, 2, 0, 8)
		t1.Rotation = 34
		t1.Parent = tri
		corner(t1, 1)
		local t2 = t1:Clone()
		t2.Position = UDim2.new(0.5, 1, 0.5, 2.6)
		t2.Rotation = -34
		t2.Parent = tri
	else
		local bl = label(badge, {
			text = (a.badge and a.badge.text) or "", ts = 13, bold = true, color = C.onAccent,
			align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
		})
		bl.TextYAlignment = Enum.TextYAlignment.Center
		bl.TextWrapped = false
	end

	local txt = frame(btn, { transparent = true, autoY = false, size = UDim2.new(1, -57, 1, 0), order = 2 })
	local tv = vlist(txt, 2); tv.VerticalAlignment = Enum.VerticalAlignment.Center
	if a.kicker then
		local k = label(txt, {
			text = a.kicker, ts = 9, bold = true, color = col, truncate = true, order = 1,
			size = UDim2.new(1, 0, 0, 11),
		})
		k.TextYAlignment = Enum.TextYAlignment.Center
	end
	local t = label(txt, {
		text = a.title or "", ts = 13, bold = true, color = C.textBright,
		truncate = true, order = 2, size = UDim2.new(1, 0, 0, 16),
	})
	t.TextYAlignment = Enum.TextYAlignment.Center
	if a.subtitle then
		local s = label(txt, {
			text = a.subtitle, ts = 10, medium = true, color = C.textSec,
			truncate = true, order = 3, size = UDim2.new(1, 0, 0, 13),
		})
		s.TextYAlignment = Enum.TextYAlignment.Center
	end

	local chev = label(btn, {
		text = "›", ts = 20, bold = true, color = col,
		align = Enum.TextXAlignment.Center, size = UDim2.new(0, 14, 1, 0), order = 3,
	})
	chev.TextYAlignment = Enum.TextYAlignment.Center
	chev.TextWrapped = false

	-- Le chevron avance de 3 px au survol : le mouvement dit « ça mène quelque
	-- part » mieux qu'un changement de couleur. On anime la LARGEUR et non la
	-- position : le UIListLayout du bouton repositionne ses enfants à chaque
	-- frame et écraserait un tween de Position.
	local ti = TweenInfo.new(0.16, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)
	btn.MouseEnter:Connect(function()
		TweenService:Create(chev, ti, { Size = UDim2.new(0, 17, 1, 0) }):Play()
	end)
	btn.MouseLeave:Connect(function()
		TweenService:Create(chev, ti, { Size = UDim2.new(0, 14, 1, 0) }):Play()
	end)

	return c, btn
end

-- ── entrée en fondu décalé ──
-- Échelonne l'apparition des cartes : l'écran s'installe au lieu de surgir
-- d'un bloc. On anime la TAILLE et la transparence, jamais la Position : dans
-- un UIListLayout c'est le layout qui place les enfants, et il écraserait tout
-- tween de position à la frame suivante.
local function fadeInUp(inst, index)
	local wasBg = inst.BackgroundTransparency
	inst.BackgroundTransparency = 1
	local sc = Instance.new("UIScale")
	sc.Scale = 0.985
	sc.Parent = inst
	task.delay(0.045 * (index or 0), function()
		if not inst.Parent then return end
		local ti = TweenInfo.new(0.32, Enum.EasingStyle.Quint, Enum.EasingDirection.Out)
		TweenService:Create(inst, ti, { BackgroundTransparency = wasBg }):Play()
		TweenService:Create(sc, ti, { Scale = 1 }):Play()
	end)
	return inst
end

-- ── skeleton (placeholder pulsant pendant le chargement) ──
local function skeleton(parent, height, order, myGen)
	local s = frame(parent, {
		bg = currentTheme == "dark" and C.elevated or C.cardHover,
		autoY = false, size = UDim2.new(1, 0, 0, height or 56), radius = R.lg, order = order,
	})
	task.spawn(function()
		while s.Parent and (myGen == nil or state.gen == myGen) do
			TweenService:Create(s, TweenInfo.new(0.75, Enum.EasingStyle.Sine), { BackgroundTransparency = 0.55 }):Play()
			task.wait(0.78)
			if not s.Parent then break end
			TweenService:Create(s, TweenInfo.new(0.75, Enum.EasingStyle.Sine), { BackgroundTransparency = 0.12 }):Play()
			task.wait(0.78)
		end
	end)
	return s
end

-- ═══════════════════════════════ SCREEN MGMT ══════════════════════════
local function clear()
	for _, c in ipairs(widget:GetChildren()) do c:Destroy() end
end

-- Nouvel écran : CanvasGroup pour un fondu propre. Retourne (root, myGen)
local function newScreen()
	state.gen += 1
	local myGen = state.gen
	clear()
	local root = Instance.new("CanvasGroup")
	root.Size = UDim2.new(1, 0, 1, 0)
	root.BackgroundColor3 = C.bg
	root.BorderSizePixel = 0
	root.GroupTransparency = 1
	root.Parent = widget
	-- fondu d'entrée
	TweenService:Create(root, TweenInfo.new(0.16, Enum.EasingStyle.Quad), { GroupTransparency = 0 }):Play()
	return root, myGen
end

-- topOffset = hauteur du header à réserver (layout déterministe, insensible à la version)
local function makeScroll(parent, topOffset)
	topOffset = topOffset or 0
	local s = Instance.new("ScrollingFrame")
	s.Position = UDim2.new(0, 0, 0, topOffset)
	s.Size = UDim2.new(1, 0, 1, -topOffset)
	s.BackgroundTransparency = 1
	s.BorderSizePixel = 0
	s.ScrollBarThickness = 3
	s.ScrollBarImageColor3 = C.textMuted
	s.ScrollBarImageTransparency = 0.45
	s.ScrollingDirection = Enum.ScrollingDirection.Y
	s.CanvasSize = UDim2.new(0, 0, 0, 0)
	s.AutomaticCanvasSize = Enum.AutomaticSize.Y
	s.Parent = parent
	local l = vlist(s, 10)
	pad(s, 14, 14, 14, 20)
	return s, l
end

-- ── toast overlay ──
-- Design repris des notifications du site : carte de surface, pastille
-- d'icône colorée à gauche, liseré de la couleur sémantique. Un seul toast
-- visible à la fois (le nouveau remplace l'ancien) pour ne rien masquer.
local _activeToast
local function toast(msg, kind)
	local color = kind == "success" and C.green or kind == "error" and C.red or C.accent
	local glyph = kind == "success" and "✓" or kind == "error" and "!" or "i"

	if _activeToast and _activeToast.Parent then
		local old = _activeToast
		_activeToast = nil
		TweenService:Create(old, TweenInfo.new(0.14), { Position = UDim2.new(0.5, 0, 1, 80) }):Play()
		task.delay(0.16, function() if old then old:Destroy() end end)
	end

	local holder = Instance.new("Frame")
	holder.AnchorPoint = Vector2.new(0.5, 1)
	holder.Position = UDim2.new(0.5, 0, 1, 80)
	holder.Size = UDim2.new(1, -24, 0, 0)
	holder.AutomaticSize = Enum.AutomaticSize.Y
	holder.BackgroundColor3 = currentTheme == "dark" and C.elevated or C.bgSurface
	holder.BorderSizePixel = 0
	-- Au-dessus des modales (voile 100 / panneau 101) : un toast déclenché
	-- depuis une fenêtre — « Vérifier les mises à jour » — doit rester
	-- visible, sinon le clic semble sans effet.
	holder.ZIndex = 200
	holder.Parent = widget
	corner(holder, R.md)
	stroke(holder, color, 1.5)
	pad(holder, 10, 10, 12, 10)
	local hl = hlist(holder, 10, Enum.VerticalAlignment.Top)
	hl.SortOrder = Enum.SortOrder.LayoutOrder
	_activeToast = holder

	local badge = Instance.new("Frame")
	badge.BackgroundColor3 = color
	badge.BorderSizePixel = 0
	badge.Size = UDim2.new(0, 20, 0, 20)
	badge.LayoutOrder = 1
	badge.ZIndex = 201
	badge.Parent = holder
	corner(badge, R.pill)
	local bi = Instance.new("TextLabel")
	bi.BackgroundTransparency = 1
	bi.Size = UDim2.new(1, 0, 1, 0)
	bi.Font = Enum.Font.GothamBold
	bi.TextSize = 12
	bi.TextColor3 = C.white
	bi.Text = glyph
	bi.ZIndex = 202
	bi.AutoLocalize = false
	bi.Parent = badge

	local t = Instance.new("TextLabel")
	t.BackgroundTransparency = 1
	-- Largeur restante = 100% − padding (10+12) − badge (20) − gap (10) = -52.
	-- C'était -30 : le texte débordait du cadre et se retrouvait coupé.
	t.Size = UDim2.new(1, -52, 0, 0)
	t.AutomaticSize = Enum.AutomaticSize.Y
	t.Font = Enum.Font.GothamMedium
	t.TextSize = 12
	t.TextColor3 = C.textBright
	t.TextWrapped = true
	t.LineHeight = 1.2
	t.TextXAlignment = Enum.TextXAlignment.Left
	t.TextYAlignment = Enum.TextYAlignment.Top
	t.Text = msg
	t.LayoutOrder = 2
	t.ZIndex = 201
	t.AutoLocalize = false
	t.Parent = holder

	TweenService:Create(holder, TweenInfo.new(0.26, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
		{ Position = UDim2.new(0.5, 0, 1, -12) }):Play()
	-- Les erreurs restent plus longtemps : elles portent une consigne à lire
	-- et à comprendre, pas juste une confirmation.
	task.delay(kind == "error" and 6.0 or 3.0, function()
		if holder and holder.Parent then
			if _activeToast == holder then _activeToast = nil end
			local tw = TweenService:Create(holder, TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.In),
				{ Position = UDim2.new(0.5, 0, 1, 80) })
			tw:Play()
			tw.Completed:Wait()
			if holder then holder:Destroy() end
		end
	end)
end

-- ═══════════════════════════════ API ══════════════════════════════════
-- apiRaw retourne (body_table, http_status) pour pouvoir lire les erreurs.
-- Essaie les bases API dans l'ordre (prod puis dev) et mémorise celle qui marche.
-- Une seule base : pas de fallback, pas de bascule. Si learnblox.fr ne
-- répond pas, on le dit — plutôt que de rediriger en silence ailleurs.
local function apiRaw(method, endpoint, body)
	local headers = {
		["Content-Type"] = "application/json",
		["X-Plugin-Version"] = VERSION,
	}
	if state.userId then
		headers["X-User-Id"] = tostring(state.userId)
	end

	local t0 = os.clock()
	local ok, resp = pcall(function()
		return HttpService:RequestAsync({
			Url = API_BASE .. endpoint,
			Method = method,
			Headers = headers,
			Body = body and HttpService:JSONEncode(body) or nil,
		})
	end)

	-- Journal des appels, réservé au mode dev. Placé ICI et non aux points
	-- d'appel : tout passe par apiRaw, donc rien ne peut être oublié.
	local function logCall(status, err)
		if not state.dev then return end
		state.devLogs = state.devLogs or {}
		table.insert(state.devLogs, {
			t = os.date("%H:%M:%S"),
			method = method,
			endpoint = endpoint,
			status = status,
			ms = math.floor((os.clock() - t0) * 1000),
			err = err,
		})
		-- Borné : au-delà, ce sont les appels récents qui comptent.
		if #state.devLogs > 60 then table.remove(state.devLogs, 1) end
	end

	if not ok or not resp then
		logCall(0, "réseau")
		return nil, 0
	end

	local status = resp.StatusCode or 0
	logCall(status, nil)
	local decoded, data = pcall(function() return HttpService:JSONDecode(resp.Body) end)
	return (decoded and data or nil), status
end

local function api(method, endpoint, body)
	local data, status = apiRaw(method, endpoint, body)
	if status >= 200 and status < 300 then return data end
	return nil
end

-- ══════════════════ CONTEXTE STUDIO POUR BLOXI ════════════════════════
-- Ce que Bloxi « voit » quand on lui parle depuis Studio. C'est toute la
-- différence avec un chat ouvert dans un navigateur à côté : l'utilisateur
-- demande « pourquoi ma Part tombe ? » sans rien décrire, parce que la Part
-- sélectionnée, le script ouvert et les erreurs du dernier test partent avec
-- la question.
--
-- Tout est borné à la source : le corps de la requête est plafonné à 64 ko
-- côté serveur, et une place fournie dépasserait sans ces limites.

-- Propriétés utiles selon la classe. Envoyer TOUTES les propriétés d'une Part
-- noierait le modèle sous 80 champs sans intérêt (CFrame, Mass, etc.).
local CTX_PROPS = {
	BasePart = { "Anchored", "CanCollide", "Transparency", "Material", "Size", "Position", "Color" },
	Part = { "Anchored", "CanCollide", "Transparency", "Material", "Size", "Position", "Color", "Shape" },
	MeshPart = { "Anchored", "CanCollide", "Transparency", "Material", "Size" },
	Humanoid = { "Health", "MaxHealth", "WalkSpeed", "JumpPower" },
	Model = { "PrimaryPart" },
	IntValue = { "Value" }, NumberValue = { "Value" },
	StringValue = { "Value" }, BoolValue = { "Value" },
	ScreenGui = { "Enabled", "ResetOnSpawn" },
	TextLabel = { "Text", "Visible" }, TextButton = { "Text", "Visible" },
	Frame = { "Visible", "BackgroundTransparency" },
	SpotLight = { "Brightness", "Range" }, PointLight = { "Brightness", "Range" },
	Sound = { "SoundId", "Volume", "Looped", "Playing" },
}

-- Rend une valeur de propriété lisible en une ligne de texte.
local function _ctxValue(v)
	local t = typeof(v)
	if t == "Vector3" then
		return string.format("(%.1f, %.1f, %.1f)", v.X, v.Y, v.Z)
	elseif t == "Color3" then
		return string.format("RGB(%d, %d, %d)",
			math.floor(v.R * 255 + 0.5), math.floor(v.G * 255 + 0.5), math.floor(v.B * 255 + 0.5))
	elseif t == "EnumItem" then
		return tostring(v.Name)
	elseif t == "Instance" then
		return v.Name
	elseif t == "boolean" or t == "number" or t == "string" then
		return tostring(v)
	end
	return nil -- type non affichable : on l'omet plutôt que d'écrire "userdata"
end

-- Chemin lisible d'une instance, tronqué à la racine du DataModel.
local function _ctxPath(inst)
	local ok, full = pcall(function() return inst:GetFullName() end)
	if ok and full then return full end
	return inst.Name
end

-- Cherche un emplacement LIBRE devant la caméra pour y poser une nouvelle
-- construction. Sans ça, Bloxi n'a aucune notion d'espace : il omet les
-- positions, les parts naissent en (0,0,0) — pile sur le SpawnLocation — et
-- la construction se retrouve encastrée dans le décor existant.
local function _computeBuildOrigin()
	local cam = workspace.CurrentCamera
	if not cam then return nil end
	local camPos = cam.CFrame.Position
	-- 30 studs devant la caméra, à plat : c'est ce que l'utilisateur regarde.
	local look = cam.CFrame.LookVector
	local flat = Vector3.new(look.X, 0, look.Z)
	if flat.Magnitude < 0.01 then flat = Vector3.new(0, 0, -1) end
	local candidate = camPos + flat.Unit * 30
	candidate = Vector3.new(candidate.X, 0, candidate.Z)

	-- Décale tant que l'emplacement est occupé : on teste une boîte de 24
	-- studs et on s'écarte par pas de 20 le long du regard.
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = { workspace.Terrain }
	for i = 0, 6 do
		local test = candidate + flat.Unit * (i * 20)
		local hits = workspace:GetPartBoundsInBox(
			CFrame.new(test + Vector3.new(0, 8, 0)),
			Vector3.new(24, 16, 24), params
		)
		-- Le sol (une baseplate large et plate) ne compte pas comme obstacle :
		-- sinon aucun emplacement ne serait jamais libre.
		local blocked = false
		for _, p in ipairs(hits) do
			local okSize = pcall(function() return p.Size end)
			if okSize and not (p.Size.X > 100 and p.Size.Y < 5) then
				blocked = true break
			end
		end
		if not blocked then
			return Vector3.new(
				math.floor(test.X + 0.5), 0, math.floor(test.Z + 0.5)
			)
		end
	end
	return Vector3.new(math.floor(candidate.X + 0.5), 0, math.floor(candidate.Z + 0.5))
end

-- Les scripts d'EXERCICE (dossier « LearnBlox » dans ServerScriptService,
-- nommés LearnBlox_<module>_<exo>) appartiennent au parcours pédagogique, pas
-- au jeu de l'élève. Les envoyer dans le contexte poussait Bloxi à les prendre
-- pour le projet en cours : à « crée un leaderstats coins », il répondait en
-- réécrivant le script de l'exercice en cours — hors sujet, et destructeur
-- pour le travail de l'élève.
-- L'exercice se pilote par le mode guidage (ctx.exerciseTitle), jamais en
-- éditant le fichier.
local function _isExerciseScript(inst)
	if not inst then return false end
	local okName = pcall(function() return inst.Name end)
	if okName and tostring(inst.Name):match("^LearnBlox_") then return true end
	local p = inst.Parent
	while p do
		if p.Name == "LearnBlox" then return true end
		p = p.Parent
	end
	return false
end

local function collectStudioContext(question)
	local ctx = {}

	pcall(function()
		local n = game.Name
		if n and n ~= "" and n ~= "game" then ctx.placeName = n end
	end)

	-- ── Repères spatiaux ──
	-- Le modèle doit savoir OÙ poser ce qu'il crée. Ces valeurs alimentent le
	-- bloc « REPÈRES SPATIAUX » du prompt.
	pcall(function()
		local spatial = {}
		local cam = workspace.CurrentCamera
		if cam then
			local p = cam.CFrame.Position
			spatial.camera = string.format("%d, %d, %d",
				math.floor(p.X + 0.5), math.floor(p.Y + 0.5), math.floor(p.Z + 0.5))
		end
		-- Le SpawnLocation est la zone À NE PAS ENCOMBRER : c'est là que les
		-- joueurs apparaissent.
		local spawn = workspace:FindFirstChildWhichIsA("SpawnLocation", true)
		if spawn then
			local p = spawn.Position
			spatial.spawnLocation = string.format("%d, %d, %d",
				math.floor(p.X + 0.5), math.floor(p.Y + 0.5), math.floor(p.Z + 0.5))
		end
		local origin = _computeBuildOrigin()
		if origin then
			spatial.buildOrigin = string.format("%d, %d, %d",
				origin.X, origin.Y, origin.Z)
		end
		-- Hauteur du sol sous le point de construction : poser à Y=0 quand la
		-- baseplate est à Y=-10 laisserait la construction en lévitation.
		if origin then
			local rp = RaycastParams.new()
			rp.FilterType = Enum.RaycastFilterType.Exclude
			rp.FilterDescendantsInstances = {}
			local hit = workspace:Raycast(
				origin + Vector3.new(0, 200, 0), Vector3.new(0, -400, 0), rp)
			if hit then
				spatial.groundY = math.floor(hit.Position.Y + 0.5)
			end
		end
		if next(spatial) then ctx.spatial = spatial end
	end)

	-- ── Sélection dans l'Explorer ──
	pcall(function()
		local sel = Selection:Get()
		if not sel or #sel == 0 then return end
		local out = {}
		-- 6 objets suffisent : au-delà, l'utilisateur a sélectionné un dossier
		-- entier et ne pose pas une question sur un objet précis.
		for i = 1, math.min(#sel, 6) do
			local inst = sel[i]
			local entry = { name = inst.Name, className = inst.ClassName }
			pcall(function()
				entry.parent = inst.Parent and inst.Parent.Name or nil
			end)
			-- Propriétés de la classe, ou de BasePart pour toute pièce.
			local list = CTX_PROPS[inst.ClassName]
			if not list then
				local okIs = pcall(function() return inst:IsA("BasePart") end)
				if okIs and inst:IsA("BasePart") then list = CTX_PROPS.BasePart end
			end
			if list then
				local props = {}
				for _, p in ipairs(list) do
					local okP, val = pcall(function() return inst[p] end)
					if okP then
						local s = _ctxValue(val)
						if s then props[p] = s end
					end
				end
				if next(props) then entry.props = props end
			end
			out[#out + 1] = entry
		end
		if #out > 0 then ctx.selection = out end
	end)

	-- ── Script ouvert dans l'éditeur ──
	-- On prend le document actif s'il y en a un ; sinon le script sélectionné.
	pcall(function()
		local doc
		local okDoc = pcall(function()
			doc = ScriptEditorService:FindScriptDocument(nil)
		end)
		-- FindScriptDocument(nil) n'est pas fiable partout : on repasse par la
		-- sélection, qui couvre le cas « je viens de cliquer sur mon script ».
		if not (okDoc and doc) then
			local sel = Selection:Get()
			for _, inst in ipairs(sel or {}) do
				if inst:IsA("LuaSourceContainer") and not _isExerciseScript(inst) then
					ctx.scriptName = inst.Name
					ctx.scriptSource = string.sub(getScriptSource(inst), 1, 4000)
					return
				end
			end
			return
		end
		local scr = doc:GetScript()
		-- Un script d'exercice ouvert dans l'éditeur ne doit pas devenir « le
		-- script sur lequel on travaille » : Bloxi proposerait de le réécrire.
		if scr and not _isExerciseScript(scr) then
			ctx.scriptName = scr.Name
			ctx.scriptSource = string.sub(doc:GetText(), 1, 4000)
		end
	end)

	-- ── Erreurs du dernier playtest ──
	pcall(function()
		local errs = select(2, pcall(function() return plugin:GetSetting(SETTING_ERRORS) end))
		if type(errs) == "table" and #errs > 0 then
			local out = {}
			for i = math.max(1, #errs - 5), #errs do out[#out + 1] = tostring(errs[i]) end
			ctx.errors = out
		end
	end)

	-- ── Exercice en cours ──
	-- Déclenche le mode guidage : Bloxi explique mais ne donne pas la solution.
	pcall(function()
		local exCtx = select(2, pcall(function() return plugin:GetSetting(SETTING_CTX) end))
		if type(exCtx) == "table" and exCtx.exId then
			ctx.exerciseTitle = exCtx.exTitle or exCtx.moduleTitle
		end
	end)

	-- ── Inventaire du place, FILTRÉ par la question ──
	-- Le plugin connaît tout l'arbre, mais n'en envoie qu'une partie : une
	-- place fournie représente des milliers d'instances, soit bien plus que ce
	-- qu'un modèle peut lire utilement (et le corps de requête est plafonné à
	-- 64 ko). On sélectionne donc ce qui a des chances de servir :
	--   1. les scripts (presque toute question technique en dépend) ;
	--   2. les objets dont le nom apparaît dans la question ;
	--   3. les enfants directs des services courants, pour la structure.
	pcall(function()
		local q = string.lower(question or "")
		local SERVICES = {
			workspace, game:GetService("ServerScriptService"),
			game:GetService("ReplicatedStorage"), game:GetService("StarterGui"),
			game:GetService("StarterPlayer"), game:GetService("Lighting"),
			game:GetService("ServerStorage"),
		}

		local scripts, named, structure = {}, {}, {}
		local seen = {}
		local BUDGET = 60 -- lignes d'inventaire, toutes catégories confondues

		-- Un mot de la question désigne-t-il cet objet ?
		local function nameHit(n)
			if #q < 3 then return false end
			local ln = string.lower(n)
			if #ln >= 3 and q:find(ln, 1, true) then return true end
			-- L'inverse aussi : « la porte » doit trouver « PorteAutomatique ».
			for word in q:gmatch("[%a%d_]+") do
				if #word >= 4 and ln:find(word, 1, true) then return true end
			end
			return false
		end

		local function add(list, inst, depth)
			if seen[inst] or #scripts + #named + #structure >= BUDGET then return end
			seen[inst] = true
			list[#list + 1] = string.rep("  ", depth or 0) .. _ctxPath(inst) .. " [" .. inst.ClassName .. "]"
		end

		-- 1 & 2 : parcours en profondeur, borné.
		local scriptInsts = {} -- instances, pour en lire le CODE plus bas
		local visited = 0
		local function walk(inst, depth)
			if visited > 4000 or depth > 6 then return end
			for _, child in ipairs(inst:GetChildren()) do
				visited += 1
				if visited > 4000 then return end
				local okIs = pcall(function() return child:IsA("LuaSourceContainer") end)
				if okIs and child:IsA("LuaSourceContainer") then
					add(scripts, child, 0)
					if #scriptInsts < 12 then scriptInsts[#scriptInsts + 1] = child end
				elseif nameHit(child.Name) then
					add(named, child, 0)
				end
				walk(child, depth + 1)
			end
		end
		for _, svc in ipairs(SERVICES) do
			if svc then pcall(function() walk(svc, 0) end) end
		end

		-- 3 : structure de surface, seulement s'il reste de la place.
		for _, svc in ipairs(SERVICES) do
			if svc and #scripts + #named + #structure < BUDGET then
				local kids = svc:GetChildren()
				if #kids > 0 then
					structure[#structure + 1] = svc.Name .. " (" .. #kids .. " enfants) : " ..
						(function()
							local names = {}
							for i = 1, math.min(#kids, 6) do
								names[#names + 1] = kids[i].Name .. " [" .. kids[i].ClassName .. "]"
							end
							if #kids > 6 then names[#names + 1] = "…" end
							return table.concat(names, ", ")
						end)()
				end
			end
		end

		local parts = {}
		if #named > 0 then
			parts[#parts + 1] = "OBJETS MENTIONNÉS DANS LA QUESTION :\n" .. table.concat(named, "\n")
		end
		if #structure > 0 then
			parts[#parts + 1] = "STRUCTURE :\n" .. table.concat(structure, "\n")
		end
		if #parts > 0 then
			ctx.inventory = table.concat(parts, "\n\n"):sub(1, 3000)
		end

		-- ── LE CODE de tous les scripts, pas seulement leurs noms ──
		-- C'est le point le plus important du contexte. Un bug tient très
		-- souvent à la CONTRADICTION entre deux scripts (le serveur donne des
		-- coins à chaque saut, le client croit les avoir dépensés). En n'en
		-- envoyant qu'un seul, Bloxi ne pouvait pas voir le conflit : il
		-- proposait des correctifs au hasard, sur un script qu'il ne lisait
		-- même pas.
		-- Le budget dépend du modèle qui recevra le contexte. Le mode agent
		-- (Premium) passe par Claude et sa très large fenêtre : on envoie tout.
		-- Le chat normal passe par Groq, plafonné à 8000 tokens PAR MINUTE :
		-- au-delà d'environ 4000 caractères de code, la requête entière est
		-- rejetée en 413 et l'utilisateur ne reçoit aucune réponse.
		local riche = (state.premium == true) or (state.dev == true)
		local codes, budget = {}, riche and 14000 or 4000
		local perScript = riche and 3000 or 1200
		for _, s in ipairs(scriptInsts) do
			if budget <= 0 then break end
			if _isExerciseScript(s) then continue end
			local okSrc, src = pcall(function() return getScriptSource(s) end)
			if okSrc and src and src ~= "" then
				local slice = src:sub(1, math.min(perScript, budget))
				budget -= #slice
				codes[#codes + 1] = string.format("--- %s [%s] ---\n%s",
					_ctxPath(s), s.ClassName, slice)
			end
		end
		if #codes > 0 then
			ctx.allCode = table.concat(codes, "\n\n")
			ctx.allCodeCount = #scriptInsts
			ctx.allCodeOmitted = math.max(0, #scripts - #codes)
		end
	end)

	return ctx
end

-- ══════════════════ MODE AGENT : ACTIONS DE BLOXI ═════════════════════
-- Bloxi propose des actions ; l'utilisateur les applique d'un clic. Rien ne
-- s'exécute tout seul, et chaque application est encadrée par un waypoint
-- d'historique — un Ctrl+Z annule tout le bloc.
--
-- L'application réutilise les helpers du système de PROJETS
-- (_resolveOrCreateParent, _setInstanceProps, setScriptSource), déjà éprouvés
-- sur des centaines d'étapes. Écrire un second applicateur en parallèle est ce
-- qui avait fragilisé la première version de Bloxi.

-- Sépare le texte des blocs <ACTION>{…}</ACTION>.
-- Retourne (texte, actions[]).
local function parseActions(reply)
	local actions = {}
	local text = tostring(reply or "")
	-- Motif tolérant : le modèle glisse parfois des espaces ou un retour à la
	-- ligne autour du JSON.
	text = text:gsub("<ACTION>%s*(.-)%s*</ACTION>", function(json)
		local ok, obj = pcall(function() return HttpService:JSONDecode(json) end)
		if ok and type(obj) == "table" and type(obj.type) == "string" then
			actions[#actions + 1] = obj
		else
			-- JSON illisible : on l'ignore plutôt que d'afficher une carte
			-- cassée. Tracé pour le mode dev.
			warn("[LearnBlox] Action illisible : " .. tostring(json):sub(1, 120))
		end
		return "" -- retiré du texte affiché
	end)
	return (text:gsub("\n\n\n+", "\n\n"):gsub("^%s+", ""):gsub("%s+$", "")), actions
end

-- Résout un chemin "ServerScriptService.Dossier.Script" en instance, en
-- créant les Folder manquants. Retourne (parent, nomFinal) ou nil.
local function _resolveScriptPath(path)
	local segs = {}
	for seg in tostring(path or ""):gmatch("[^%.]+") do segs[#segs + 1] = seg end
	if #segs == 0 then return nil end
	local leaf = table.remove(segs)
	local parent = _resolveOrCreateParent(table.concat(segs, "."))
	if not parent then return nil end
	return parent, leaf
end

-- Repose au sol les parts qui « flottent ». Le prompt demande au modèle
-- d'empiler ses pièces les unes sur les autres, mais aucun LLM ne calcule des
-- offsets 3D de façon fiable : il reste toujours des morceaux en lévitation.
-- Ce filet corrige APRÈS coup ce que le prompt ne peut que recommander.
-- On ne touche qu'aux parts nettement décollées (> 0.6 stud) et sans rien
-- au-dessous : abaisser une pièce volontairement suspendue (une enseigne, un
-- auvent) casserait la construction.
local function _settleFloatingParts(model, groundY)
	local moved = 0
	local okAll = pcall(function()
		local parts = {}
		for _, d in ipairs(model:GetDescendants()) do
			if d:IsA("BasePart") then parts[#parts + 1] = d end
		end
		-- Trie du plus bas au plus haut : une pile se pose de bas en haut,
		-- sinon on repose un élément sur un support pas encore descendu.
		table.sort(parts, function(x, y) return x.Position.Y < y.Position.Y end)
		local params = RaycastParams.new()
		params.FilterType = Enum.RaycastFilterType.Exclude
		for _, p in ipairs(parts) do
			params.FilterDescendantsInstances = { p }
			-- 4 rayons aux coins plutôt qu'un au centre : un auvent posé sur
			-- des poteaux n'a RIEN sous son centre, mais bien un appui à ses
			-- extrémités. Avec un seul rayon central on le croyait flottant et
			-- on le faisait tomber sur le comptoir — c'est ce qui écrasait la
			-- construction (19 parts « repositionnées » à tort).
			local hx, hz = p.Size.X / 2 - 0.05, p.Size.Z / 2 - 0.05
			local best = nil
			for _, off in ipairs({
				Vector3.new(0, 0, 0),
				Vector3.new(hx, 0, hz), Vector3.new(-hx, 0, hz),
				Vector3.new(hx, 0, -hz), Vector3.new(-hx, 0, -hz),
			}) do
				local hit = workspace:Raycast(p.Position + off, Vector3.new(0, -200, 0), params)
				local y = hit and hit.Position.Y or nil
				-- On retient l'appui le PLUS HAUT : c'est celui sur lequel la
				-- pièce repose réellement.
				if y and (not best or y > best) then best = y end
			end
			local supportY = best or groundY
			if supportY then
				local bottom = p.Position.Y - p.Size.Y / 2
				local gap = bottom - supportY
				-- Fenêtre VOLONTAIREMENT étroite : on ne corrige qu'un écart
				-- manifestement accidentel (jusqu'à 2 studs). Au-delà, la
				-- pièce est suspendue exprès — auvent, enseigne, toit — et y
				-- toucher casse plus qu'elle ne répare. Dans le doute : ne
				-- rien faire.
				if gap > 0.6 and gap <= 2 then
					p.Position = Vector3.new(p.Position.X, supportY + p.Size.Y / 2, p.Position.Z)
					moved += 1
				end
			end
		end
	end)
	return okAll and moved or 0
end

-- Applique UNE action. Retourne (ok, message).
local function applyAction(a)
	if type(a) ~= "table" then return false, "action vide" end

	if a.type == "create" then
		if type(a.items) ~= "table" or #a.items == 0 then return false, "rien à créer" end
		local n, maj = 0, 0
		for _, item in ipairs(a.items) do
			local okOne = pcall(function()
				-- Un Script ne se crée JAMAIS ici : son code doit passer par
				-- une action "script", que l'utilisateur relit.
				local cls = tostring(item.class or "")
				if cls == "" or cls:find("Script") then return end
				local parent = _resolveOrCreateParent(item.parent)
				if not parent then return end
				local name = tostring(item.name or cls)

				-- IDEMPOTENT : si un objet du même nom ET de la même classe
				-- existe déjà à cet endroit, on le MET À JOUR au lieu d'en
				-- ajouter un second. Sans ça, réappliquer une action (ou
				-- redemander la même chose à Bloxi) empilait les ScreenGui
				-- les uns sur les autres — d'où les « GUI sur des GUI ».
				local existing = parent:FindFirstChild(name)
				if existing and existing.ClassName == cls then
					_setInstanceProps(existing, item.props)
					maj += 1
					return
				end
				-- Même nom mais classe différente : on ne touche pas, sinon on
				-- écraserait autre chose que ce que Bloxi croit modifier.
				if existing then return end

				local inst = Instance.new(cls)
				inst.Name = name
				_setInstanceProps(inst, item.props)
				inst.Parent = parent
				n += 1
			end)
			if not okOne then
				-- Une classe inconnue fait échouer Instance.new : on continue
				-- avec les suivantes plutôt que d'abandonner tout le bloc.
			end
		end
		if n == 0 and maj == 0 then return false, "aucun objet créé (classe ou parent invalide)" end
		local parts = {}
		if n > 0 then parts[#parts + 1] = n .. " créé" .. (n > 1 and "s" or "") end
		if maj > 0 then parts[#parts + 1] = maj .. " mis à jour" end
		return true, table.concat(parts, ", ")

	elseif a.type == "script" then
		local code = tostring(a.code or "")
		if code == "" then return false, "code vide" end
		-- Les scripts d'exercice sont hors de portée des actions : écraser
		-- LearnBlox_module_5_ex_1 détruirait le travail de l'élève, et c'est
		-- exactement ce que le modèle proposait quand ces scripts entraient
		-- dans son contexte. Le filtrage côté contexte doit suffire ; ceci en
		-- est le garde-fou dur.
		local tgt = tostring(a.target or "")
		if tgt:match("LearnBlox_") or tgt:match("%.LearnBlox%.") or tgt:match("^LearnBlox%.") then
			return false, "les scripts d'exercice sont protégés (dossier LearnBlox)"
		end
		local parent, leaf = _resolveScriptPath(a.target)
		if not parent or not leaf then return false, "chemin invalide" end
		if _isExerciseScript(parent) or leaf:match("^LearnBlox_") then
			return false, "les scripts d'exercice sont protégés (dossier LearnBlox)"
		end

		local existing = parent:FindFirstChild(leaf)
		if existing and not existing:IsA("LuaSourceContainer") then
			return false, leaf .. " existe déjà et n'est pas un script"
		end
		local scr = existing
		if not scr then
			local cls = tostring(a.class or "Script")
			if cls ~= "Script" and cls ~= "LocalScript" and cls ~= "ModuleScript" then
				cls = "Script"
			end
			local okNew = pcall(function()
				scr = Instance.new(cls)
				scr.Name = leaf
				scr.Parent = parent
			end)
			if not okNew or not scr then return false, "création impossible" end
		end
		setScriptSource(scr, code)
		return true, (existing and "écrit dans " or "créé ") .. leaf

	elseif a.type == "build" then
		-- Construction MATÉRIALISÉE tout de suite dans le place, au lieu d'un
		-- script qui bâtirait au lancement du jeu. Un décor doit exister en
		-- mode Edit : l'élève le voit, le déplace, le modifie — et il survit à
		-- l'arrêt du test. Un Script dans ServerScriptService produisait
		-- l'inverse : rien de visible, et tout disparaissait après le playtest.
		local code = tostring(a.code or "")
		if code == "" then return false, "code de construction vide" end
		-- loadstring est désactivé par défaut, mais le plugin s'exécute avec
		-- les droits Studio : on tente, et on dit clairement si c'est refusé.
		local okLoad, fn = pcall(function() return loadstring(code) end)
		if not okLoad or type(fn) ~= "function" then
			return false, "code de construction illisible (loadstring indisponible ou syntaxe invalide)"
		end
		local created = {}
		-- On observe ce qui apparaît sous Workspace pour pouvoir sélectionner
		-- le résultat ensuite : sans ça l'élève ne sait pas où regarder.
		local before = {}
		for _, ch in ipairs(workspace:GetChildren()) do before[ch] = true end
		local okRun, err = pcall(fn)
		if not okRun then
			return false, "la construction a échoué : " .. tostring(err):sub(1, 140)
		end
		for _, ch in ipairs(workspace:GetChildren()) do
			-- On ne retient que ce que le code a VRAIMENT construit. Les
			-- services (Terrain, Camera, VideoService…) vivent aussi sous
			-- Workspace : les inclure faisait parcourir tout le moteur par le
			-- rattrapage géométrique, d'où l'avertissement « The Parent
			-- property of VideoService is locked ».
			if not before[ch] then
				local okCls = pcall(function()
					return ch:IsA("Model") or ch:IsA("Folder") or ch:IsA("BasePart")
				end)
				if okCls and (ch:IsA("Model") or ch:IsA("Folder") or ch:IsA("BasePart")) then
					created[#created + 1] = ch
				end
			end
		end
		-- Rattrapage géométrique : le modèle laisse toujours des pièces en
		-- lévitation. On les repose avant de montrer le résultat.
		local settled = 0
		local groundY = nil
		pcall(function()
			local origin = _computeBuildOrigin()
			if origin then
				local rp = RaycastParams.new()
				local hit = workspace:Raycast(origin + Vector3.new(0, 200, 0), Vector3.new(0, -400, 0), rp)
				if hit then groundY = hit.Position.Y end
			end
		end)
		for _, inst in ipairs(created) do
			if inst:IsA("Model") or inst:IsA("Folder") then
				settled += _settleFloatingParts(inst, groundY)
			end
		end

		if #created > 0 then
			-- Sélectionner + cadrer : l'élève voit immédiatement le résultat.
			pcall(function() Selection:Set(created) end)
		end
		local n = 0
		for _, inst in ipairs(created) do
			n += 1
			local okD, d = pcall(function() return #inst:GetDescendants() end)
			if okD then n += d end
		end
		return true, (#created > 0)
			and ("construit : " .. created[1].Name .. " (" .. n .. " objets"
				.. (settled > 0 and (", " .. settled .. " repositionnés") or "") .. ")")
			or "construction appliquée"

	elseif a.type == "modify" then
		local inst = resolveByPath(a.target)
		if not inst then return false, "introuvable : " .. tostring(a.target) end
		_setInstanceProps(inst, a.props)
		return true, "modifié : " .. inst.Name

	elseif a.type == "delete" then
		-- Supprimer est la seule action DESTRUCTRICE : elle est protégée.
		-- Les services eux-mêmes ne se suppriment jamais (on viderait la
		-- place), et le dossier LearnBlox contient les exercices de
		-- l'utilisateur — le perdre serait irréparable.
		local PROTEGES = {
			Workspace = true, ServerScriptService = true, ReplicatedStorage = true,
			StarterGui = true, StarterPlayer = true, Lighting = true,
			ServerStorage = true, Players = true, StarterPack = true,
			SoundService = true, Teams = true, LearnBlox = true,
		}
		local targets = {}
		if type(a.targets) == "table" then
			for _, t in ipairs(a.targets) do targets[#targets + 1] = t end
		elseif a.target then
			targets[1] = a.target
		end
		if #targets == 0 then return false, "rien à supprimer" end

		-- Repli quand le chemin exact ne résout pas : Bloxi désigne parfois un
		-- objet par son nom seul (« supprime ChaiseMusicale »). On le cherche
		-- alors dans les services habituels. Sans ce repli, la suppression
		-- échouait en silence et il semblait mentir.
		local function findByName(name)
			local SERVICES = {
				workspace, game:GetService("ServerScriptService"),
				game:GetService("ReplicatedStorage"), game:GetService("StarterGui"),
				game:GetService("StarterPlayer"), game:GetService("ServerStorage"),
				game:GetService("Lighting"),
			}
			for _, svc in ipairs(SERVICES) do
				local okFind, found = pcall(function()
					return svc:FindFirstChild(name, true) -- true = récursif
				end)
				if okFind and found then return found end
			end
			return nil
		end

		local n, refus = 0, {}
		for _, path in ipairs(targets) do
			local inst = resolveByPath(path)
			if not inst then
				-- Dernier segment du chemin : « StarterGui.MonGui » → « MonGui »
				local leaf = tostring(path):match("([^%.]+)$")
				if leaf then inst = findByName(leaf) end
			end
			if not inst then
				refus[#refus + 1] = "introuvable : " .. tostring(path)
			elseif PROTEGES[inst.Name] and inst.Parent == game then
				refus[#refus + 1] = inst.Name .. " est un service, non supprimable"
			elseif inst.Name == "LearnBlox" then
				refus[#refus + 1] = "le dossier LearnBlox est protégé"
			else
				local okDel = pcall(function() inst:Destroy() end)
				if okDel then n += 1 else refus[#refus + 1] = "échec : " .. tostring(path) end
			end
		end
		if n == 0 then
			return false, (#refus > 0 and refus[1] or "aucune suppression")
		end
		return true, n .. " objet" .. (n > 1 and "s supprimés" or " supprimé")
			.. (#refus > 0 and (" (" .. #refus .. " refusé" .. (#refus > 1 and "s" or "") .. ")") or "")

	elseif a.type == "rename" then
		local inst = resolveByPath(a.target)
		if not inst then return false, "introuvable : " .. tostring(a.target) end
		local newName = tostring(a.name or "")
		if newName == "" then return false, "nom vide" end
		local old = inst.Name
		local okRen = pcall(function() inst.Name = newName end)
		if not okRen then return false, "renommage impossible" end
		return true, old .. " → " .. newName
	end

	return false, "type inconnu : " .. tostring(a.type)
end

-- ── Vérification de mise à jour ────────────────────────────────────────
-- Compare la version locale (VERSION) à celle publiée par le serveur.
-- Si obsolète, mémorise l'info (affichée dans l'UI) et notifie l'utilisateur.
-- Le plugin ne pouvant pas ouvrir un navigateur, on invite à aller sur le site.
local function checkForUpdate()
	local info = api("GET", "/version?v=" .. VERSION)
	if type(info) ~= "table" then return end
	state.updateInfo = info
	if info.outdated then
		toast("Mise à jour disponible (v" .. tostring(info.latest) .. ") — télécharge-la sur learnblox.fr", "info")
	end
end

-- ═══════════════════════════════ SHARED UI ════════════════════════════
-- Chargement : squelettes pulsants plutôt qu'un spinner. La page garde sa
-- forme finale, donc l'arrivée du contenu ne fait pas "sauter" l'interface —
-- même approche que les écrans de chargement du site.
local function loadingState(parent, myGen, msg, shape)
	local box = frame(parent, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 320), order = 5 })
	local l = vlist(box, 10)
	l.SortOrder = Enum.SortOrder.LayoutOrder
	if shape == "detail" then
		skeleton(box, 78, 1, myGen)
		skeleton(box, 96, 2, myGen)
		skeleton(box, 132, 3, myGen)
	else
		skeleton(box, 72, 1, myGen)   -- carte de progression
		skeleton(box, 20, 2, myGen)   -- titre de section
		for i = 1, 5 do skeleton(box, 58, i + 2, myGen) end
	end
	return box
end

-- Chargement plein écran centré (connexion initiale) : là, aucune forme
-- finale n'est connue, donc un indicateur animé reste le bon choix.
local function loadingSplash(parent, myGen, msg)
	local box = frame(parent, { transparent = true, autoY = false, size = UDim2.new(1, 0, 1, 0), order = 5 })
	local center = Instance.new("Frame")
	center.BackgroundTransparency = 1
	center.AnchorPoint = Vector2.new(0.5, 0.5)
	center.Position = UDim2.new(0.5, 0, 0.5, 0)
	center.Size = UDim2.new(1, -32, 0, 0)
	center.AutomaticSize = Enum.AutomaticSize.Y
	center.Parent = box
	local l = vlist(center, 14)
	l.HorizontalAlignment = Enum.HorizontalAlignment.Center

	-- Logo animé (pulsation douce) — plus dans l'esprit de la marque
	local logo = frame(center, { bg = C.accent, autoY = false, size = UDim2.new(0, 44, 0, 44), radius = R.lg, order = 1 })
	gradient(logo, C.accent, C.accentDark, 45)
	local ll = label(logo, { text = "L", ts = 26, bold = true, color = C.onAccent, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
	ll.TextYAlignment = Enum.TextYAlignment.Center
	ll.TextWrapped = false

	local dots = label(center, { text = msg or "Connexion…", ts = 12, medium = true, color = C.textSec, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 0, 16), order = 2 })
	dots.TextWrapped = false

	task.spawn(function()
		local base = msg or "Connexion"
		base = base:gsub("[.…]+$", "")
		local i = 0
		while state.gen == myGen and dots.Parent do
			TweenService:Create(logo, TweenInfo.new(0.6, Enum.EasingStyle.Sine), { BackgroundTransparency = 0.3 }):Play()
			dots.Text = base .. string.rep(".", i % 3 + 1)
			task.wait(0.6)
			if not dots.Parent then break end
			TweenService:Create(logo, TweenInfo.new(0.6, Enum.EasingStyle.Sine), { BackgroundTransparency = 0 }):Play()
			task.wait(0.6)
			i += 1
		end
	end)
	return box
end

-- header réutilisable (barre supérieure colorée)
local function makeHeader(parent, opts)
	local hdr = frame(parent, { bg = opts.bg or C.card, autoY = false, size = UDim2.new(1, 0, 0, opts.height or 48), order = 1 })
	hdr.ClipsDescendants = true
	hdr.ZIndex = 5
	pad(hdr, 12, 0, 12, 0)
	local row = frame(hdr, { transparent = true, autoY = false, size = UDim2.new(1, 0, 1, 0) })
	row.ZIndex = 5
	hlist(row, 8)
	return hdr, row
end

-- ═════════════════════ BARRE D'ONGLETS FLOTTANTE (bas) ════════════════
-- Navigation principale façon application mobile : une pilule posée au-dessus
-- du contenu, avec l'onglet Bloxi en pastille centrale surélevée.
--
-- Pourquoi en bas : le panneau est haut et étroit (360×680 par défaut), donc
-- proche d'un écran de téléphone. La main va au pouce, l'œil au dernier tiers.
-- En haut, la nav se disputait la place avec le wordmark et les stats.
--
-- Les icônes sont DESSINÉES en frames, jamais en Unicode : la police de Studio
-- rend ☰ ✦ ⚙ en carré « tofu » (même raison que les boutons du header).
local TABBAR_H = 62        -- hauteur réservée sous le contenu
local TABBAR_INSET = 10    -- marge entre la pilule et les bords du widget

-- Trait/point rectangulaire dans un canevas d'icône. `col` permet de teindre
-- l'icône selon l'état actif ; les parts renvoyées sont retintées au survol.
local function ipx(parent, x, y, w, h, col, radius, rot)
	local f = Instance.new("Frame")
	f.BackgroundColor3 = col
	f.BorderSizePixel = 0
	f.AnchorPoint = Vector2.new(0.5, 0.5)
	f.Position = UDim2.new(0, x, 0, y)
	f.Size = UDim2.new(0, w, 0, h)
	f.Rotation = rot or 0
	f.ZIndex = 12
	f.Parent = parent
	if radius then corner(f, radius) end
	return f
end

-- Chaque dessin travaille dans un canevas 18×18 et renvoie ses parts.
local TAB_ICONS = {
	-- Accueil : toit en chevron + corps de maison
	accueil = function(cv, col)
		local p = {}
		p[#p+1] = ipx(cv, 6.2, 6.6, 9, 1.9, col, 1, -38)
		p[#p+1] = ipx(cv, 11.8, 6.6, 9, 1.9, col, 1, 38)
		p[#p+1] = ipx(cv, 9, 12.4, 10, 1.9, col, 1)
		p[#p+1] = ipx(cv, 4.5, 11.5, 1.9, 5.6, col, 1)
		p[#p+1] = ipx(cv, 13.5, 11.5, 1.9, 5.6, col, 1)
		return p
	end,
	-- Apprendre : deux barres empilées + curseur, évoque une liste de leçons
	apprendre = function(cv, col)
		local p = {}
		p[#p+1] = ipx(cv, 9, 4.5, 12, 1.9, col, 1)
		p[#p+1] = ipx(cv, 7.6, 9, 9.2, 1.9, col, 1)
		p[#p+1] = ipx(cv, 9, 13.5, 12, 1.9, col, 1)
		return p
	end,
	-- Bloxi : étoile à quatre branches (la marque de l'assistant).
	-- Les branches font 14 et non 15+ : au-delà, les pointes touchaient le bord
	-- du canevas et le rognage asymétrique donnait une étoile qui « penche ».
	bloxi = function(cv, col)
		local p = {}
		p[#p+1] = ipx(cv, 9, 9, 2.4, 14, col, 1.2)
		p[#p+1] = ipx(cv, 9, 9, 14, 2.4, col, 1.2)
		p[#p+1] = ipx(cv, 9, 9, 2, 9, col, 1, 45)
		p[#p+1] = ipx(cv, 9, 9, 9, 2, col, 1, 45)
		return p
	end,
}

-- Construit la barre. `tabs` = { {id, label, center?}, … }, `active` = id.
-- `onSelect(id)` est appelé au clic sur un onglet inactif.
local function makeTabBar(parent, tabs, active, onSelect)
	-- Conteneur ancré en bas, hors flux : il flotte au-dessus du scroll.
	local holder = Instance.new("Frame")
	holder.BackgroundTransparency = 1
	holder.AnchorPoint = Vector2.new(0.5, 1)
	holder.Position = UDim2.new(0.5, 0, 1, -TABBAR_INSET)
	holder.Size = UDim2.new(1, -TABBAR_INSET * 2, 0, TABBAR_H - TABBAR_INSET)
	holder.ZIndex = 10
	holder.Parent = parent

	-- Ombre portée : une copie décalée et assombrie sous la pilule. Studio n'a
	-- pas de box-shadow ; c'est la façon standard de suggérer l'élévation.
	local shadow = Instance.new("Frame")
	shadow.BackgroundColor3 = C.black
	shadow.BackgroundTransparency = currentTheme == "dark" and 0.72 or 0.9
	shadow.BorderSizePixel = 0
	shadow.AnchorPoint = Vector2.new(0.5, 0.5)
	shadow.Position = UDim2.new(0.5, 0, 0.5, 3)
	shadow.Size = UDim2.new(1, 6, 1, 2)
	shadow.ZIndex = 9
	shadow.Parent = holder
	corner(shadow, R.pill)

	local bar = Instance.new("Frame")
	bar.BackgroundColor3 = currentTheme == "dark" and C.elevated or C.card
	bar.BorderSizePixel = 0
	bar.Size = UDim2.new(1, 0, 1, 0)
	bar.ZIndex = 10
	bar.Parent = holder
	corner(bar, R.pill)
	local barStroke = stroke(bar, C.border, 1)
	barStroke.Transparency = currentTheme == "dark" and 0.5 or 0.25

	local n = #tabs
	for i, tab in ipairs(tabs) do
		local isActive = (tab.id == active)
		local isCenter = tab.center == true

		-- Cellule : positionnée en fraction, pas en UIListLayout — l'onglet
		-- central déborde vers le haut et casserait un layout en flux.
		local cell = Instance.new("TextButton")
		cell.BackgroundTransparency = 1
		cell.Text = ""
		cell.AutoButtonColor = false
		cell.AnchorPoint = Vector2.new(0.5, 0.5)
		cell.Position = UDim2.new((i - 0.5) / n, 0, 0.5, 0)
		cell.Size = UDim2.new(1 / n, -2, 1, 0)
		cell.ZIndex = 11
		cell.Parent = bar

		local iconCol = isActive and C.accent or C.textMuted

		if isCenter then
			-- ── Onglet central : pastille pleine, surélevée hors de la barre ──
			-- C'est l'action distinctive du plugin : elle mérite un traitement
			-- qui ne ressemble à aucun autre onglet.
			local knobSize = 42
			local knob = Instance.new("Frame")
			knob.BackgroundColor3 = C.accent
			knob.BorderSizePixel = 0
			knob.AnchorPoint = Vector2.new(0.5, 0.5)
			knob.Position = UDim2.new(0.5, 0, 0.5, -13)
			knob.Size = UDim2.new(0, knobSize, 0, knobSize)
			knob.ZIndex = 12
			knob.Parent = cell
			corner(knob, R.pill)
			gradient(knob, C.accent, C.accentDark, 90)

			-- Anneau de la couleur du fond : détache la pastille de la barre
			-- quand elle la chevauche.
			local ring = stroke(knob, currentTheme == "dark" and C.elevated or C.card, 3)
			ring.Transparency = 0

			local cv = Instance.new("Frame")
			cv.BackgroundTransparency = 1
			cv.AnchorPoint = Vector2.new(0.5, 0.5)
			cv.Position = UDim2.new(0.5, 0, 0.5, 0)
			cv.Size = UDim2.new(0, 18, 0, 18)
			cv.ZIndex = 12
			cv.Parent = knob
			TAB_ICONS.bloxi(cv, C.onAccent)

			local lbl = label(cell, {
				text = tab.label, ts = 9, bold = true,
				color = isActive and C.accent or C.textMuted,
				align = Enum.TextXAlignment.Center,
				size = UDim2.new(1, 0, 0, 10),
			})
			lbl.AnchorPoint = Vector2.new(0.5, 1)
			lbl.Position = UDim2.new(0.5, 0, 1, -5)
			lbl.ZIndex = 12
			lbl.TextWrapped = false

			-- Le survol soulève légèrement la pastille : le seul onglet à
			-- réagir ainsi, ce qui renforce son statut d'action principale.
			cell.MouseEnter:Connect(function()
				TweenService:Create(knob, TweenInfo.new(0.16, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
					{ Size = UDim2.new(0, knobSize + 4, 0, knobSize + 4), Position = UDim2.new(0.5, 0, 0.5, -16) }):Play()
			end)
			cell.MouseLeave:Connect(function()
				TweenService:Create(knob, TweenInfo.new(0.16, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
					{ Size = UDim2.new(0, knobSize, 0, knobSize), Position = UDim2.new(0.5, 0, 0.5, -13) }):Play()
			end)
		else
			-- ── Onglet standard : icône + libellé, empilés ──
			local cv = Instance.new("Frame")
			cv.BackgroundTransparency = 1
			cv.AnchorPoint = Vector2.new(0.5, 0)
			cv.Position = UDim2.new(0.5, 0, 0, 9)
			cv.Size = UDim2.new(0, 18, 0, 18)
			cv.ZIndex = 11
			cv.Parent = cell

			local drawer = TAB_ICONS[tab.id] or TAB_ICONS.accueil
			local parts = drawer(cv, iconCol)

			local lbl = label(cell, {
				text = tab.label, ts = 9, bold = isActive, medium = not isActive,
				color = iconCol, align = Enum.TextXAlignment.Center,
				size = UDim2.new(1, 0, 0, 11),
			})
			lbl.AnchorPoint = Vector2.new(0.5, 1)
			lbl.Position = UDim2.new(0.5, 0, 1, -8)
			lbl.ZIndex = 11
			lbl.TextWrapped = false

			-- Point d'état sous l'onglet actif : repère discret, plus adapté à
			-- une barre arrondie qu'un soulignement rectangulaire.
			if isActive then
				local dot = Instance.new("Frame")
				dot.BackgroundColor3 = C.accent
				dot.BorderSizePixel = 0
				dot.AnchorPoint = Vector2.new(0.5, 1)
				dot.Position = UDim2.new(0.5, 0, 1, -3)
				dot.Size = UDim2.new(0, 0, 0, 3)
				dot.ZIndex = 11
				dot.Parent = cell
				corner(dot, R.pill)
				TweenService:Create(dot, TweenInfo.new(0.28, Enum.EasingStyle.Quint, Enum.EasingDirection.Out),
					{ Size = UDim2.new(0, 14, 0, 3) }):Play()
			else
				cell.MouseEnter:Connect(function()
					for _, p in ipairs(parts) do p.BackgroundColor3 = C.textBright end
					lbl.TextColor3 = C.textBright
				end)
				cell.MouseLeave:Connect(function()
					for _, p in ipairs(parts) do p.BackgroundColor3 = C.textMuted end
					lbl.TextColor3 = C.textMuted
				end)
			end
		end

		if not isActive then
			cell.MouseButton1Click:Connect(function() onSelect(tab.id) end)
		end
	end

	return holder
end



-- Barre de navigation d'un écran interne : ‹ retour + titre (+ sous-titre)
-- + zone d'action à droite. Remplace le bricolage répété dans chaque écran.
-- `onBack` reçoit le clic ; `trailing(container)` peut ajouter des boutons.
local NAV_H = 52
local function makeNavBar(parent, opts)
	local hdr, row = makeHeader(parent, { height = NAV_H, bg = C.bgSurface })
	stroke(hdr, C.borderSoft)

	local back = button(row, {
		text = "‹", size = UDim2.new(0, 32, 0, 32), variant = "ghost",
		tc = C.textSec, ts = 22, radius = R.sm, order = 1, onClick = opts.onBack,
	})
	back.TextWrapped = false
	back.MouseEnter:Connect(function() back.TextColor3 = C.accent end)
	back.MouseLeave:Connect(function() back.TextColor3 = C.textSec end)

	local trailingW = opts.trailingWidth or 0
	local titleWrap = frame(row, { transparent = true, autoY = false, size = UDim2.new(1, -40 - trailingW, 1, 0), order = 2 })
	local tl = vlist(titleWrap, 1); tl.VerticalAlignment = Enum.VerticalAlignment.Center

	if opts.eyebrow and opts.eyebrow ~= "" then
		label(titleWrap, { text = opts.eyebrow, ts = 9, bold = true, color = C.accent, truncate = true, size = UDim2.new(1, 0, 0, 12), order = 1 })
	end
	label(titleWrap, { text = opts.title or "", ts = 13, bold = true, color = C.textBright, truncate = true, size = UDim2.new(1, 0, 0, 17), order = 2 })

	return hdr, row, titleWrap
end

-- ═══════════════════════════════ SCREENS ══════════════════════════════
local showModules, showExercises, showExercise, showError, showProject, showPairingScreen

-- Nettoie le contexte d'exercice "actif" (lu par la validation au playtest).
-- Appelé dès qu'on quitte un exercice (accueil, liste, projet) : ainsi un F5
-- lancé pendant le projet ne valide plus par erreur le dernier exercice ouvert.
local function clearExerciseCtx()
	pcall(function() plugin:SetSetting(SETTING_CTX, nil) end)
end
local function clearProjectCtx()
	pcall(function() plugin:SetSetting(SETTING_PROJ_CTX, nil) end)
end

-- ── ERREUR / RETRY ──
-- État vide illustré : pastille d'icône, titre, explication actionnable,
-- CTA de reprise. Même anatomie que les états vides du site.
showError = function(msg, retryFn)
	local root = newScreen()
	local wrap = frame(root, { transparent = true, autoY = false, size = UDim2.new(1, 0, 1, 0) })
	local box = frame(wrap, { transparent = true })
	box.AnchorPoint = Vector2.new(0.5, 0.5)
	box.Position = UDim2.new(0.5, 0, 0.5, 0)
	box.Size = UDim2.new(1, -44, 0, 0)
	local l = vlist(box, 10)
	l.HorizontalAlignment = Enum.HorizontalAlignment.Center

	local iconBox = frame(box, { bg = C.redBg, autoY = false, size = UDim2.new(0, 52, 0, 52), radius = R.lg, order = 1, stroke = C.red })
	local ic = label(iconBox, { text = "!", ts = 26, bold = true, color = C.red, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
	ic.TextYAlignment = Enum.TextYAlignment.Center
	ic.TextWrapped = false

	label(box, { text = "Connexion impossible", ts = 15, bold = true, color = C.textBright, align = Enum.TextXAlignment.Center, order = 2 })
	label(box, { text = msg or "Impossible de joindre learnblox.fr. Vérifie ta connexion, puis réessaie.", ts = 12, color = C.textSec, align = Enum.TextXAlignment.Center, order = 3 })

	local hint = frame(box, { bg = C.bgSurface, radius = R.md, order = 4, stroke = C.borderSoft })
	pad(hint, 12, 9, 12, 9)
	label(hint, { text = "Astuce : dans Studio, active les requêtes HTTP via Game Settings, onglet Security, option Allow HTTP Requests.", ts = 11, color = C.textMuted, align = Enum.TextXAlignment.Center })

	if retryFn then
		button(box, { text = "Réessayer", size = UDim2.new(0, 170, 0, 40), variant = "primary", ts = 13, radius = R.md, order = 5, onClick = retryFn })
	end
end

-- ── MODULES ──
showModules = function(initialTab)
	local root, myGen = newScreen()
	clearExerciseCtx(); clearProjectCtx() -- écran d'accueil : rien d'"actif"
	-- Mémorise le dernier onglet choisi pour respecter le contexte à la réouverture
	if initialTab then
		pcall(function() plugin:SetSetting("LB_last_tab", initialTab) end)
	end

	-- ── HEADER : marque + actions ──
	-- Réduit à l'essentiel : le wordmark et les icônes (thème, actualiser,
	-- infos, déconnexion). La navigation est descendue dans la barre
	-- flottante, et l'identité (salutation, série, Premium) est remontée en
	-- tête de l'accueil, où elle a la place d'exister vraiment.
	-- 132 → 62 : autant de gagné pour le contenu.
	local HEADER_H = 62
	local hdr = frame(root, { bg = C.bgSurface, autoY = false, size = UDim2.new(1, 0, 0, HEADER_H), order = 1 })
	hdr.ClipsDescendants = true
	hdr.ZIndex = 3

	-- Filet de séparation net (1px) plutôt qu'un contour complet
	local hairline = frame(hdr, { bg = C.border, autoY = false, size = UDim2.new(1, 0, 0, 1) })
	hairline.Position = UDim2.new(0, 0, 1, -1)
	hairline.ZIndex = 4

	pad(hdr, 14, 11, 14, 0)
	local hdrContent = frame(hdr, { transparent = true, autoY = false, size = UDim2.new(1, 0, 1, 0) })
	vlist(hdrContent, 9)

	-- Row 1 : logo + wordmark + actions
	local topRow = frame(hdrContent, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 30), order = 1 })
	hlist(topRow, 7)

	local logoBox = frame(topRow, { bg = C.accent, autoY = false, size = UDim2.new(0, 28, 0, 28), radius = R.sm, order = 1 })
	gradient(logoBox, C.accent, C.accentDark, 45)
	local logoL = label(logoBox, { text = "L", ts = 17, bold = true, color = C.onAccent, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
	logoL.TextYAlignment = Enum.TextYAlignment.Center
	logoL.TextWrapped = false

	-- -180 : 4 boutons de 28 px + leurs 4 gouttières de 7 = 140, plus une marge
	-- de sécurité. À -145 (valeur d'avant l'ajout de l'icône d'infos) il ne
	-- restait que 5 px : le wordmark touchait le premier bouton.
	local wordmark = label(topRow, { text = "LearnBlox Studio", ts = 16, bold = true, color = C.textBright, size = UDim2.new(1, -180, 0, 28), order = 2, truncate = true, rich = true })
	-- "Studio" en gris et non en accent : la marque reste LearnBlox, Studio
	-- n'est que le nom de la surface. Deux couleurs vives se disputeraient l'œil.
	wordmark.Text = string.format('Learn<font color="rgb(%d,%d,%d)">Blox</font> <font color="rgb(%d,%d,%d)">Studio</font>',
		math.floor(C.accent.R * 255 + 0.5), math.floor(C.accent.G * 255 + 0.5), math.floor(C.accent.B * 255 + 0.5),
		math.floor(C.textSec.R * 255 + 0.5), math.floor(C.textSec.G * 255 + 0.5), math.floor(C.textSec.B * 255 + 0.5))
	wordmark.TextYAlignment = Enum.TextYAlignment.Center

	-- Boutons d'action : icônes DESSINÉES (frames), pas des caractères.
	-- Les pictogrammes Unicode (☀, ⟳, ⎋…) tombent en carré « tofu » dans la
	-- police de Studio ; des formes géométriques s'affichent toujours.
	-- Infobulle des icônes du header. Posée sur `root` et non dans le header :
	-- celui-ci a ClipsDescendants, une bulle placée dedans serait tranchée à
	-- son bord inférieur.
	local _tip
	local function hideTip()
		if _tip then _tip:Destroy(); _tip = nil end
	end
	local function showTip(anchor, txt)
		hideTip()
		local t = Instance.new("Frame")
		t.Name = "LBTip"
		t.BackgroundColor3 = currentTheme == "dark" and C.elevated or C.textBright
		t.BorderSizePixel = 0
		t.AutomaticSize = Enum.AutomaticSize.XY
		t.AnchorPoint = Vector2.new(0.5, 0)
		-- Sous le bouton : position absolue convertie en coordonnées de root.
		local ax = anchor.AbsolutePosition.X - root.AbsolutePosition.X + anchor.AbsoluteSize.X / 2
		local ay = anchor.AbsolutePosition.Y - root.AbsolutePosition.Y + anchor.AbsoluteSize.Y + 6
		t.Position = UDim2.new(0, ax, 0, ay)
		t.Size = UDim2.new(0, 0, 0, 0)
		t.ZIndex = 40
		t.Parent = root
		corner(t, R["2xs"])
		pad(t, 8, 5, 8, 5)
		local l = label(t, {
			text = txt, ts = 11, medium = true,
			color = currentTheme == "dark" and C.textBright or C.bg,
			size = UDim2.new(0, 0, 0, 13),
		})
		l.AutomaticSize = Enum.AutomaticSize.X
		l.TextWrapped = false
		l.ZIndex = 41
		-- Apparition en fondu : sans elle, la bulle « claque » au survol.
		t.BackgroundTransparency = 1
		l.TextTransparency = 1
		TweenService:Create(t, TweenInfo.new(0.12), { BackgroundTransparency = 0 }):Play()
		TweenService:Create(l, TweenInfo.new(0.12), { TextTransparency = 0 }):Play()
		_tip = t
	end

	local function iconAction(order, onClick, draw, tipText)
		local b = button(topRow, {
			text = "", size = UDim2.new(0, 28, 0, 28), variant = "ghost",
			radius = R.sm, order = order, onClick = onClick,
		})
		local canvas = Instance.new("Frame")
		canvas.BackgroundTransparency = 1
		canvas.AnchorPoint = Vector2.new(0.5, 0.5)
		canvas.Position = UDim2.new(0.5, 0, 0.5, 0)
		canvas.Size = UDim2.new(0, 16, 0, 16)
		canvas.Parent = b
		local parts = draw(canvas)
		-- Survol : les traits passent en couleur vive
		b.MouseEnter:Connect(function()
			for _, p in ipairs(parts) do p.BackgroundColor3 = C.textBright end
			if tipText then showTip(b, tipText) end
		end)
		b.MouseLeave:Connect(function()
			for _, p in ipairs(parts) do p.BackgroundColor3 = C.textMuted end
			hideTip()
		end)
		-- Un clic ferme la bulle : garder l'ancienne visible pendant que
		-- l'écran se reconstruit laisserait une étiquette orpheline.
		b.MouseButton1Click:Connect(hideTip)
		return b
	end

	-- Petit helper : un trait/point rectangulaire dans le canevas 16×16
	local function tick(parent, x, y, w, h, radius, rot)
		local f = Instance.new("Frame")
		f.BackgroundColor3 = C.textMuted
		f.BorderSizePixel = 0
		f.AnchorPoint = Vector2.new(0.5, 0.5)
		f.Position = UDim2.new(0, x, 0, y)
		f.Size = UDim2.new(0, w, 0, h)
		f.Rotation = rot or 0
		f.Parent = parent
		if radius then corner(f, radius) end
		return f
	end

	-- Thème : soleil (disque + rayons) en sombre, lune (croissant) en clair
	iconAction(4, function()
		applyTheme(currentTheme == "dark" and "light" or "dark")
		pcall(function() plugin:SetSetting("LB_theme", currentTheme) end)
		showModules(initialTab)
	end, function(canvas)
		local parts = {}
		if currentTheme == "dark" then
			parts[#parts + 1] = tick(canvas, 8, 8, 8, 8, R.pill)      -- disque
			for i = 0, 3 do                                            -- 4 rayons
				parts[#parts + 1] = tick(canvas, 8, 8, 15, 1.6, 1, i * 45)
			end
			-- Le disque doit rester au-dessus des rayons
			parts[1].ZIndex = 3
			local halo = tick(canvas, 8, 8, 11, 11, R.pill)
			halo.BackgroundColor3 = C.card
			halo.ZIndex = 2
		else
			-- Croissant : un disque plein masqué par un disque de la couleur du fond
			parts[#parts + 1] = tick(canvas, 7, 8, 13, 13, R.pill)
			local cut = tick(canvas, 11.5, 6, 11, 11, R.pill)
			cut.BackgroundColor3 = C.card
			cut.ZIndex = 3
		end
		return parts
	end, currentTheme == "dark" and "Passer en clair" or "Passer en sombre")

	-- Actualiser : arc ouvert (anneau masqué) + ergot formant la flèche
	iconAction(5, function()
		state.modulesCache = nil
		toast("Actualisation…", "info")
		showModules(initialTab)
	end, function(canvas)
		local parts = {}
		local ring = tick(canvas, 8, 8, 14, 14, R.pill)                -- anneau plein
		local hole = tick(canvas, 8, 8, 9, 9, R.pill)                  -- évidement
		hole.BackgroundColor3 = C.card
		hole.ZIndex = 3
		local gap = tick(canvas, 12.5, 3.5, 6, 6, 1)                   -- ouverture de l'arc
		gap.BackgroundColor3 = C.card
		gap.ZIndex = 4
		parts[#parts + 1] = ring
		local head = tick(canvas, 12, 4.5, 5, 2, 1, 40)                -- pointe de flèche
		head.ZIndex = 5
		parts[#parts + 1] = head
		return parts
	end, "Actualiser les données")

	-- Infos du plugin : version, auteur, compte relié. En popup et non en
	-- carte dans l'accueil — c'est une information de dépannage, consultée
	-- une fois tous les six mois, elle n'a pas à occuper l'écran en permanence.
	iconAction(3, function()
		-- Un seul panneau à la fois : reclic = fermeture.
		local existing = root:FindFirstChild("LBAboutPopup")
		if existing then existing:Destroy() return end

		-- Voile plein écran : cliquer à côté ferme, comme une vraie modale.
		local veil = Instance.new("TextButton")
		veil.Name = "LBAboutPopup"
		veil.Size = UDim2.new(1, 0, 1, 0)
		veil.BackgroundColor3 = C.black
		veil.BackgroundTransparency = 0.55
		veil.BorderSizePixel = 0
		veil.Text = ""
		veil.AutoButtonColor = false
		veil.ZIndex = 100
		veil.AutoLocalize = false
		veil.Parent = root
		veil.MouseButton1Click:Connect(function() veil:Destroy() end)

		-- Le panneau est un TextButton et non un Frame : il ABSORBE le clic,
		-- qui sinon traverserait jusqu'au voile et fermerait la fenêtre dès
		-- qu'on clique dedans. Un enfant « mangeur de clic » ne marche pas ici
		-- (le UIListLayout du panneau lui réserverait une ligne pleine hauteur).
		local panel = Instance.new("TextButton")
		panel.Size = UDim2.new(1, -40, 0, 0)
		panel.AutomaticSize = Enum.AutomaticSize.Y
		panel.AnchorPoint = Vector2.new(0.5, 0.5)
		panel.Position = UDim2.new(0.5, 0, 0.5, 0)
		panel.BackgroundColor3 = C.card
		panel.BorderSizePixel = 0
		panel.Text = ""
		panel.AutoButtonColor = false
		panel.ZIndex = 101
		panel.AutoLocalize = false
		panel.Parent = veil
		corner(panel, R.lg)
		stroke(panel, C.border, 1)
		pad(panel, 16, 15, 16, 15)
		vlist(panel, 10)

		-- Tout le contenu monte au-dessus du panneau. Sans ZIndex explicite,
		-- les enfants héritent de 1 et passent SOUS le voile (ZIndex 100) —
		-- c'est ce qui faisait apparaître le texte par-dessus la page.
		local Z = 102

		-- En-tête : logo + nom + version
		local aHead = frame(panel, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 32), order = 1 })
		aHead.ZIndex = Z
		hlist(aHead, 10)

		local aLogo = frame(aHead, { bg = C.accent, autoY = false, size = UDim2.new(0, 30, 0, 30), radius = R.sm, order = 1 })
		aLogo.ZIndex = Z
		gradient(aLogo, C.accent, C.accentDark, 45)
		local aLogoL = label(aLogo, {
			text = "L", ts = 18, bold = true, color = C.onAccent,
			align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
		})
		aLogoL.TextYAlignment = Enum.TextYAlignment.Center
		aLogoL.TextWrapped = false
		aLogoL.ZIndex = Z + 1

		local aTxt = frame(aHead, { transparent = true, autoY = false, size = UDim2.new(1, -40, 1, 0), order = 2 })
		aTxt.ZIndex = Z
		vlist(aTxt, 1)
		local aName = label(aTxt, {
			text = "LearnBlox Studio", ts = 14, bold = true, color = C.textBright,
			order = 1, truncate = true, size = UDim2.new(1, 0, 0, 17),
		})
		aName.TextYAlignment = Enum.TextYAlignment.Center
		aName.ZIndex = Z

		-- « à jour » n'est affiché que si le serveur a répondu : l'annoncer
		-- sans vérification serait faux.
		local upd = state.updateInfo
		local verTxt, verColor = "Version " .. VERSION, C.textSec
		if type(upd) == "table" and upd.latest then
			if upd.outdated then
				verTxt = "Version " .. VERSION .. "  ·  v" .. tostring(upd.latest) .. " disponible"
				verColor = C.gold
			else
				verTxt = "Version " .. VERSION .. "  ·  à jour"
				verColor = C.green
			end
		end
		local aVer = label(aTxt, {
			text = verTxt, ts = 11, medium = true, color = verColor,
			order = 2, truncate = true, size = UDim2.new(1, 0, 0, 14),
		})
		aVer.TextYAlignment = Enum.TextYAlignment.Center
		aVer.ZIndex = Z

		local sep = frame(panel, { bg = C.border, autoY = false, size = UDim2.new(1, 0, 0, 1), order = 2 })
		sep.ZIndex = Z

		local function infoRow(lbl, value, order, valueColor)
			local r = frame(panel, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 17), order = order })
			r.ZIndex = Z
			local l = label(r, {
				text = lbl, ts = 11, medium = true, color = C.textMuted,
				size = UDim2.new(0.45, 0, 1, 0), truncate = true,
			})
			l.TextYAlignment = Enum.TextYAlignment.Center
			l.ZIndex = Z
			local v = label(r, {
				text = value, ts = 11, bold = true, color = valueColor or C.text,
				align = Enum.TextXAlignment.Right, size = UDim2.new(0.55, 0, 1, 0), truncate = true,
			})
			v.AnchorPoint = Vector2.new(1, 0)
			v.Position = UDim2.new(1, 0, 0, 0)
			v.TextYAlignment = Enum.TextYAlignment.Center
			v.ZIndex = Z
		end

		infoRow("Développeur", "DevWithDono", 3)
		infoRow("Site", "learnblox.fr", 4, C.accent)
		infoRow("Compte relié", state.username or "—", 5)
		infoRow("Formule", state.premium and "Premium" or "Gratuit", 6,
			state.premium and C.gold or C.text)

		-- ── Zone DÉVELOPPEUR ──
		-- Visible uniquement pour un compte admin (state.dev vient de /me,
		-- qui lit le rôle en base). Le quota illimité, lui, est appliqué
		-- en SQL : falsifier state.dev ici ne débloquerait rien.
		if state.dev then
			infoRow("Mode", "DÉVELOPPEUR", 7, C.purple)

			local devSep = frame(panel, { bg = C.border, autoY = false, size = UDim2.new(1, 0, 0, 1), order = 8 })
			devSep.ZIndex = Z

			-- Bascule du mode test : aucune requête réseau, réponse simulée.
			local testBtn
			testBtn = button(panel, {
				text = state.devTestMode and "Mode test : ACTIF" or "Mode test : inactif",
				size = UDim2.new(1, 0, 0, 28),
				bg = state.devTestMode and C.purpleBg or C.card,
				tc = state.devTestMode and C.purple or C.textSec,
				ts = 11, radius = R.sm, order = 9,
				onClick = function()
					state.devTestMode = not state.devTestMode
					testBtn.Text = state.devTestMode and "Mode test : ACTIF" or "Mode test : inactif"
					testBtn.BackgroundColor3 = state.devTestMode and C.purpleBg or C.card
					testBtn.TextColor3 = state.devTestMode and C.purple or C.textSec
					toast(state.devTestMode
						and "Mode test — réponses simulées, aucun appel réseau"
						or "Mode test désactivé", "info")
				end,
			})
			testBtn.ZIndex = Z
			stroke(testBtn, state.devTestMode and C.purple or C.border, 1)


			-- Journal des appels API.
			local logsBtn = button(panel, {
				text = "Voir les logs (" .. #(state.devLogs or {}) .. ")",
				size = UDim2.new(1, 0, 0, 28),
				variant = "secondary", ts = 11, radius = R.sm, order = 11,
				onClick = function()
					local logs = state.devLogs or {}
					local out = { "===== LOGS API (" .. #logs .. ") =====" }
					if #logs == 0 then
						table.insert(out, "(aucun appel enregistré)")
					end
					for _, l in ipairs(logs) do
						table.insert(out, string.format("%s  %-4s %-28s  %s  %d ms%s",
							tostring(l.t), tostring(l.method), tostring(l.endpoint),
							tostring(l.status), tonumber(l.ms) or 0,
							l.err and ("  [" .. tostring(l.err) .. "]") or ""))
					end
					table.insert(out, "===== FIN =====")
					print(table.concat(out, "\n"))
					toast(#logs .. " appels écrits dans l'Output", "success")
				end,
			})
			logsBtn.ZIndex = Z

			-- Remise à zéro du journal, sans quitter le panneau.
			local resetBtn = button(panel, {
				text = "Vider les logs", size = UDim2.new(1, 0, 0, 28),
				variant = "danger", ts = 11, radius = R.sm, order = 12,
				onClick = function()
					state.devLogs = {}
					toast("Logs vidés", "success")
					veil:Destroy()
					showModules()
				end,
			})
			resetBtn.ZIndex = Z
		end

		-- Zone de résultat, DANS le panneau. Avant, la seule réponse au clic
		-- était un toast en bas de l'écran : si le plugin était déjà à jour,
		-- rien ne bougeait dans la fenêtre qu'on regardait — d'où l'impression
		-- que le bouton ne faisait rien.
		local resultBox = frame(panel, { bg = C.bg, autoY = true, radius = R.sm, order = 19 })
		resultBox.ZIndex = Z
		resultBox.Visible = false
		pad(resultBox, 10, 8, 10, 8)
		vlist(resultBox, 5)

		local resultLbl = label(resultBox, {
			text = "", ts = 11, medium = true, color = C.textSec, lh = 1.4, order = 1,
		})
		resultLbl.ZIndex = Z

		-- Le lien de téléchargement direct n'apparaît QUE si une version plus
		-- récente existe : le proposer en permanence inviterait à réinstaller
		-- ce qu'on a déjà.
		local dlBox = frame(resultBox, { bg = C.card, autoY = false, size = UDim2.new(1, 0, 0, 26), radius = R.xs, order = 2 })
		dlBox.ZIndex = Z
		dlBox.Visible = false
		stroke(dlBox, C.border, 1)
		pad(dlBox, 8, 0, 8, 0)
		local dlLbl = label(dlBox, {
			text = "learnblox.fr/learnblox-plugin", ts = 11, bold = true,
			color = C.accent, size = UDim2.new(1, 0, 1, 0), truncate = true,
		})
		dlLbl.TextYAlignment = Enum.TextYAlignment.Center
		dlLbl.Font = Enum.Font.Code
		dlLbl.ZIndex = Z

		-- Déclaré AVANT l'appel : `local x = button{onClick = function() x… end}`
		-- capture x AVANT son assignation, donc nil au moment du clic
		-- (« attempt to index nil with 'Text' »). La portée d'un `local` ne
		-- commence qu'après l'instruction qui le déclare.
		local checkBtn
		checkBtn = button(panel, {
			text = "Vérifier les mises à jour", size = UDim2.new(1, 0, 0, 30),
			variant = "secondary", ts = 12, radius = R.sm, order = 20,
			onClick = function()
				checkBtn.Text = "Vérification…"
				checkBtn.Active = false
				-- Le résultat précédent disparaît immédiatement : sans ça, un
				-- second clic semble ne rien faire puisque le texte est déjà là.
				resultBox.Visible = false
				dlBox.Visible = false

				task.spawn(function()
					local info = api("GET", "/version?v=" .. VERSION)
					if not checkBtn.Parent then return end
					checkBtn.Active = true
					checkBtn.Text = "Vérifier les mises à jour"
					resultBox.Visible = true

					if type(info) ~= "table" then
						resultLbl.Text = "Serveur injoignable. Vérifie ta connexion, puis réessaie."
						resultLbl.TextColor3 = C.red
						return
					end

					state.updateInfo = info
					if info.outdated then
						aVer.Text = "Version " .. VERSION .. "  ·  v" .. tostring(info.latest) .. " disponible"
						aVer.TextColor3 = C.gold
						local chg = type(info.changelog) == "string" and info.changelog ~= "" and info.changelog or nil
						resultLbl.Text = "Version " .. tostring(info.latest) .. " disponible."
							.. (chg and ("\n" .. chg) or "")
							.. "\n\nTélécharge le fichier, puis remplace l'ancien dans ton dossier Plugins."
						resultLbl.TextColor3 = C.textSec
						dlBox.Visible = true
					else
						aVer.Text = "Version " .. VERSION .. "  ·  à jour"
						aVer.TextColor3 = C.green
						-- On DATE la vérification : c'est la preuve que le clic
						-- a bien déclenché un appel, même quand rien ne change.
						local hh = os.date("%H:%M")
						resultLbl.Text = "Tu as la dernière version (" .. VERSION .. ").\nVérifié à " .. hh .. "."
						resultLbl.TextColor3 = C.green
					end
				end)
			end,
		})
		checkBtn.ZIndex = Z

		local closeBtn = button(panel, {
			text = "Fermer", size = UDim2.new(1, 0, 0, 28), variant = "ghost",
			tc = C.textSec, ts = 11, radius = R.sm, order = 21,
			onClick = function() veil:Destroy() end,
		})
		closeBtn.ZIndex = Z
	end, function(canvas)
		-- Icône « i » : un point et une hampe dans un cercle.
		local parts = {}
		local ring = tick(canvas, 8, 8, 15, 15, R.pill)
		local hole = tick(canvas, 8, 8, 12, 12, R.pill)
		hole.BackgroundColor3 = C.card
		hole.ZIndex = 3
		parts[#parts + 1] = ring
		parts[#parts + 1] = tick(canvas, 8, 5, 1.8, 1.8, R.pill)   -- point du i
		parts[#parts + 1] = tick(canvas, 8, 10, 1.8, 5, 1)         -- hampe du i
		for _, p in ipairs(parts) do p.ZIndex = 4 end
		ring.ZIndex = 2
		return parts
	end, "Infos du plugin")

	iconAction(6, function()
		local uid = state.userId -- capturer AVANT de vider
		task.spawn(function()
			local data, status = apiRaw("POST", "/unpair", { userId = uid })
			print("[LearnBlox] Unpair status: " .. tostring(status))
		end)
		state.userId = nil
		state.username = nil
		state.modulesCache = nil
		pcall(function() plugin:SetSetting(SETTING_PAIRED_USER, "") end)
		pcall(function() plugin:SetSetting(SETTING_PAIRED_NAME, "") end)
		toast("Déconnecté. Relie ton compte avec un nouveau code.", "info")
		showPairingScreen()
	end, function(canvas)
		-- Déconnexion : porte ouverte (cadre en U) + flèche sortante
		local parts = {}
		parts[#parts + 1] = tick(canvas, 5.5, 8, 8, 13, 1)             -- panneau
		local inner = tick(canvas, 7, 8, 5, 9, 1)                      -- évidement → cadre
		inner.BackgroundColor3 = C.card
		inner.ZIndex = 3
		parts[#parts + 1] = tick(canvas, 12, 8, 6, 1.6, 1)             -- hampe de la flèche
		parts[#parts + 1] = tick(canvas, 13.5, 6, 4, 1.6, 1, 45)       -- pointe haute
		parts[#parts + 1] = tick(canvas, 13.5, 10, 4, 1.6, 1, -45)     -- pointe basse
		return parts
	end, "Se déconnecter")

	-- La navigation vit dans la barre flottante en bas (makeTabBar), et
	-- l'identité (salutation, série, Premium) en tête de l'accueil.
	local okTab, savedTab = pcall(function() return plugin:GetSetting("LB_last_tab") end)
	-- "exercices" et "projets" restent acceptés en ENTRÉE : d'anciens appels
	-- (showModules("exercices")) et la valeur sauvegardée d'une version
	-- précédente doivent continuer d'ouvrir le bon sous-onglet d'Apprendre.
	local LEGACY_TABS = { exercices = "apprendre", projets = "apprendre" }
	local VALID_TABS = { accueil = true, apprendre = true, bloxi = true }

	-- Sous-onglet d'Apprendre : l'entrée explicite gagne (revenir d'un projet
	-- doit rouvrir Projets), sinon on restaure le dernier consulté.
	local okSub, savedSub = pcall(function() return plugin:GetSetting("LB_last_sub") end)
	local initialSub = (initialTab == "projets" and "projets")
		or (initialTab == "exercices" and "exercices")
		or (okSub and (savedSub == "projets" or savedSub == "exercices") and savedSub)
		or "exercices"

	local requested = initialTab or (okTab and savedTab) or "accueil"
	local activeTab = VALID_TABS[requested] and requested
		or LEGACY_TABS[requested]
		or "accueil"

	-- Trois destinations, Bloxi au centre. Pas d'onglet Réglages : le thème,
	-- l'actualisation et les infos du plugin sont déjà des icônes du header,
	-- consultées rarement — leur donner un quart de la barre les surexposait.
	local tabLabels = {
		{ id = "accueil", label = "Accueil" },
		{ id = "bloxi", label = "Bloxi", center = true },
		{ id = "apprendre", label = "Apprendre" },
	}


	local contentContainer
	-- Déclarées ici pour que renderTabContent puisse les appeler avant leur
	-- définition (elles vivent plus bas et referment sur contentContainer).
	local renderExercices, renderProjets, renderBloxi

	local renderTabContent
	renderTabContent = function(tabId, subTab)
		if contentContainer and contentContainer.Parent then contentContainer:Destroy() end
		-- Changer d'onglet passe par showModules(), qui reconstruit tout
		-- l'écran : rien d'autre à nettoyer ici.
		contentContainer = makeScroll(root, HEADER_H)
		-- Le contenu s'arrête au-dessus de la pilule de navigation, sinon la
		-- dernière carte passe dessous et devient inatteignable.
		contentContainer.Size = UDim2.new(1, 0, 1, -HEADER_H - TABBAR_H)

		if tabId == "accueil" then
			-- ── ACCUEIL : où j'en suis, et quoi faire maintenant ──
			-- Construit UNIQUEMENT sur des données réelles (/me et /modules).
			-- Pas de « défis » ni d'« événements » : la table
			-- community_challenges existe en base mais est VIDE et rien ne
			-- l'alimente (aucun writer côté serveur, aucune tâche planifiée).
			-- Afficher une carte « Défi de la semaine » perpétuellement vide
			-- serait pire que ne rien afficher.
			local cached = state.modulesCache
			if not cached then loadingState(contentContainer, myGen) end
			task.spawn(function()
				local modules = cached or api("GET", "/modules")
				if state.gen ~= myGen then return end
				if not modules then showError(nil, function() showModules("accueil") end); return end
				state.modulesCache = modules

				for _, ch in ipairs(contentContainer:GetChildren()) do
					if ch:IsA("GuiObject") and ch.Name ~= "LBSubTabs" then ch:Destroy() end
				end

				-- ── Agrégats ──
				-- La cible de reprise est calculée plus bas, avec le
				-- verrouillage premium : proposer un module Pro à un compte
				-- gratuit enverrait l'utilisateur droit dans un mur.
				local totalEx, doneEx, doneMods, playableMods = 0, 0, 0, 0
				for _, m in ipairs(modules) do
					local ec = m.exerciseCount or 0
					if ec > 0 then
						playableMods += 1
						local cc = math.min(m.completedCount or 0, ec)
						totalEx += ec
						doneEx += cc
						if cc >= ec then doneMods += 1 end
					end
				end
				local pct = (totalEx > 0) and math.floor(doneEx / totalEx * 100 + 0.5) or 0

				-- ═══ 0. L'ACCUEIL PROPREMENT DIT ═══
				-- Salutation contextuelle + avatar + badges. Descendue du header
				-- où elle tenait sur une ligne de 24 px, écrasée entre le
				-- wordmark et les onglets : ici elle a la place d'accueillir.
				local hello = frame(contentContainer, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 46), order = 0 })
				hlist(hello, 11, Enum.VerticalAlignment.Center)

				-- Avatar : initiale sur pastille dégradée. Un visage, même
				-- abstrait, vaut mieux qu'une ligne de texte pour dire « c'est toi ».
				local av = frame(hello, { bg = C.accent, autoY = false, size = UDim2.new(0, 40, 0, 40), radius = R.pill, order = 1 })
				gradient(av, C.accent, C.accentDark, 135)
				local avl = label(av, {
					text = (state.username or "?"):sub(1, 1):upper(),
					ts = 18, bold = true, color = C.onAccent,
					align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
				})
				avl.TextYAlignment = Enum.TextYAlignment.Center
				avl.TextWrapped = false

				local hTxt = frame(hello, { transparent = true, autoY = false, size = UDim2.new(1, -51, 1, 0), order = 2 })
				local hv = vlist(hTxt, 3)
				hv.VerticalAlignment = Enum.VerticalAlignment.Center

				-- Salutation selon l'heure : le plugin s'ouvre aussi bien à
				-- 8 h qu'à minuit, et un « Bonsoir » au bon moment fait
				-- beaucoup pour l'impression de présence.
				local hh = tonumber(os.date("%H")) or 12
				local mot = (hh < 6 and "Encore debout")
					or (hh < 12 and "Bonjour")
					or (hh < 18 and "Salut")
					or "Bonsoir"
				local nameL = label(hTxt, {
					text = "", ts = 15, bold = true, color = C.textBright,
					order = 1, truncate = true, rich = true, size = UDim2.new(1, 0, 0, 19),
				})
				nameL.Text = string.format('%s <font color="rgb(%d,%d,%d)">%s</font>',
					mot,
					math.floor(C.accent.R * 255 + 0.5), math.floor(C.accent.G * 255 + 0.5), math.floor(C.accent.B * 255 + 0.5),
					state.username or "dev")
				nameL.TextYAlignment = Enum.TextYAlignment.Center

				-- Sous-ligne : ce que la session dit de l'utilisateur, pas une
				-- répétition des chiffres affichés plus bas.
				local sub2
				if doneEx == 0 then
					sub2 = "Prêt pour ton premier exercice ?"
				elseif pct >= 100 then
					sub2 = "Parcours terminé — chapeau."
				elseif (state.streak or 0) >= 2 then
					sub2 = state.streak .. " jours d'affilée. Ça devient une habitude."
				else
					sub2 = "Content de te revoir."
				end
				local subL = label(hTxt, {
					text = sub2, ts = 11, medium = true, color = C.textSec,
					order = 2, truncate = true, size = UDim2.new(1, 0, 0, 14),
				})
				subL.TextYAlignment = Enum.TextYAlignment.Center

				-- Badges : série et Premium, sur leur propre ligne pour ne plus
				-- se battre avec la salutation pour la largeur.
				if (state.streak or 0) > 0 or state.premium then
					local badges = frame(contentContainer, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 22), order = 1 })
					hlist(badges, 6)
					local bo = 1
					if (state.streak or 0) > 0 then
						pill(badges, state.streak .. (state.streak > 1 and " jours de suite" or " jour de suite"),
							C.orangeBg, C.orange, bo)
						bo += 1
					end
					if state.premium then
						pill(badges, "Premium", C.accentBg, C.gold, bo)
						bo += 1
					end
				end

				-- ═══ LES DEUX FRONTS ═══
				-- L'accueil montre où on en est SUR LES DEUX tableaux : les
				-- exercices (s'entraîner) et les projets (construire). N'en
				-- afficher qu'un donnait une vue amputée — on pouvait avoir un
				-- projet en cours sans jamais le voir depuis l'accueil.
				-- Même carte que dans Apprendre : un seul motif à apprendre.

				-- Où reprendre côté exercices.
				local resumeMod, resumeIsStarted
				for _, m in ipairs(modules) do
					local ec = m.exerciseCount or 0
					local proLocked = not state.premium and
						(m.level == "pro" or m.level == "build_pro" or m.level == "monetise_pro")
					if ec > 0 and not proLocked then
						local cc = math.min(m.completedCount or 0, ec)
						if cc > 0 and cc < ec then
							resumeMod, resumeIsStarted = m, true
							break
						elseif not resumeMod and cc == 0 then
							resumeMod, resumeIsStarted = m, false
						end
					end
				end

				local allDone = playableMods > 0 and doneMods >= playableMods
				local exAction
				if resumeMod and not allDone then
					local rDone = math.min(resumeMod.completedCount or 0, resumeMod.exerciseCount or 0)
					exAction = {
						kicker = resumeIsStarted and "REPRENDRE" or "COMMENCER",
						title = resumeMod.title or resumeMod.id,
						subtitle = resumeIsStarted
							and ("exercice " .. (rDone + 1) .. " sur " .. resumeMod.exerciseCount)
							or (resumeMod.exerciseCount .. " exercice"
								.. ((resumeMod.exerciseCount or 0) > 1 and "s" or "") .. " dans ce module"),
						badge = { play = true },
						onClick = function()
							state.currentModule = resumeMod
							showExercises(resumeMod.id, resumeMod.title, true)
						end,
					}
				end

				sectionTitle(contentContainer, "S'ENTRAÎNER", 2)
				fadeInUp(statusCard(contentContainer, {
					order = 3,
					color = allDone and C.green or C.accent,
					ratio = totalEx > 0 and doneEx / totalEx or 0,
					title = allDone and "Parcours terminé" or "Exercices",
					subtitle = doneMods .. "/" .. playableMods .. " modules  ·  " .. doneEx .. "/" .. totalEx .. " exercices",
					action = exAction,
				}), 1)

				-- ── Projets : second appel, l'accueil s'affiche sans l'attendre ──
				-- Un emplacement est réservé tout de suite (order 5) pour que la
				-- carte ne fasse pas sauter la mise en page en arrivant.
				sectionTitle(contentContainer, "CONSTRUIRE", 4)
				local projSlot = frame(contentContainer, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 92), order = 5 })
				skeleton(projSlot, 92, 1, myGen)

				task.spawn(function()
					local projects = api("GET", "/projects")
					if state.gen ~= myGen then return end
					if not projSlot.Parent then return end
					projSlot:Destroy()
					if not projects or #projects == 0 then return end

					local pDone, sDone, sTotal = 0, 0, 0
					local live, nextP = nil, nil
					for _, p in ipairs(projects) do
						local t = p.stepCount or 0
						local d = math.min(p.checkedCount or 0, t)
						local fini = p.completed or (t > 0 and d >= t)
						sTotal += t
						sDone += fini and t or d
						if fini then
							pDone += 1
						else
							if d > 0 and not live then live = p end
							if not nextP then nextP = p end
						end
					end
					local target = live or nextP
					local pAll = #projects > 0 and pDone >= #projects

					local pjAction
					if target and not pAll then
						local lt = target.stepCount or 0
						local ld = math.min(target.checkedCount or 0, lt)
						local started = ld > 0
						pjAction = {
							kicker = started and "REPRENDRE" or "COMMENCER",
							title = target.projectTitle or "Projet",
							subtitle = started
								and ("étape " .. math.min(ld + 1, lt) .. " sur " .. lt)
								or (lt .. " étape" .. (lt > 1 and "s" or "") .. " à construire"),
							badge = { text = tostring(math.min(ld + 1, math.max(lt, 1))) },
							onClick = function() showProject(target.moduleId, target.projectTitle) end,
						}
					end

					-- index 0 : la carte arrive après un appel réseau, elle est
					-- déjà « en retard ». Pas de délai supplémentaire.
					fadeInUp(statusCard(contentContainer, {
						order = 5,
						color = pAll and C.green or C.purple,
						ratio = sTotal > 0 and sDone / sTotal or 0,
						title = pAll and "Tous les projets sont construits" or "Projets",
						subtitle = pDone .. "/" .. #projects .. " projets  ·  " .. sDone .. "/" .. sTotal .. " étapes",
						action = pjAction,
					}), 0)
				end)

				-- ═══ LES REPÈRES ═══
				-- Trois chiffres sur une ligne : on les consulte, on ne les
				-- déclenche pas. Le record plutôt que la série en cours, déjà
				-- affichée en badge tout en haut.
				local streakVal = state.streak or 0
				local bestVal = math.max(state.bestStreak or 0, streakVal)
				local statsRow = frame(contentContainer, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 62), order = 6 })
				local sl = hlist(statsRow, 8)
				sl.HorizontalAlignment = Enum.HorizontalAlignment.Left

				local stats = {
					{ tostring(doneEx), "exercices faits", C.accent },
					{ doneMods .. "/" .. playableMods, "modules finis", C.green },
					{ tostring(bestVal), bestVal > 1 and "jours record" or "jour record", C.orange },
				}
				for i, st in ipairs(stats) do
					-- -6 : deux gouttières de 8 réparties sur trois colonnes.
					local cell = frame(statsRow, {
						bg = C.card, autoY = false, size = UDim2.new(1 / 3, -6, 1, 0),
						radius = R.md, order = i,
					})
					local cs = stroke(cell, C.border); cs.Transparency = 0.35
					-- +2 : les tuiles entrent après les deux cartes.
					fadeInUp(cell, i + 2)
					pad(cell, 10, 10, 10, 10)
					local cv = vlist(cell, 2)
					cv.VerticalAlignment = Enum.VerticalAlignment.Center
					local nv = label(cell, {
						text = st[1], ts = 17, bold = true, color = st[3], order = 1,
						truncate = true, size = UDim2.new(1, 0, 0, 21),
					})
					nv.TextWrapped = false
					local lv = label(cell, {
						text = st[2], ts = 10, medium = true, color = C.textMuted, order = 2,
						truncate = true, size = UDim2.new(1, 0, 0, 12),
					})
					lv.TextWrapped = false
				end

				footnote(contentContainer, "Ta progression est partagée avec learnblox.fr — tu peux passer du site au plugin sans rien perdre.", 900)
			end)

		elseif tabId == "apprendre" then
			-- ── APPRENDRE : Exercices et Projets réunis ──
			-- Deux façons d'avancer, deux jeux de données (/modules et
			-- /projects), deux gestes différents : s'entraîner sur des
			-- exercices courts, ou construire un jeu étape par étape.
			-- Un sélecteur segmenté les sépare sans imposer deux onglets de
			-- premier niveau — la barre du bas reste à 3 destinations.
			local sub = subTab or "exercices"

			-- Nommé : renderExercices/renderProjets vident le conteneur avant de
			-- dessiner leur liste, et détruiraient le sélecteur avec. Ils
			-- épargnent explicitement cet enfant-là.
			local segWrap = frame(contentContainer, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 38), order = 0 })
			segWrap.Name = "LBSubTabs"
			-- Piste en `elevated` et non `bgSurface` : en thème clair, bgSurface
			-- est blanc comme les cartes, la piste devenait invisible.
			local seg = frame(segWrap, { bg = currentTheme == "dark" and C.bgSurface or C.cardHover,
				autoY = false, size = UDim2.new(1, 0, 1, 0), radius = R.pill })
			local segStroke = stroke(seg, C.border)
			segStroke.Transparency = 0.3
			pad(seg, 3, 3, 3, 3)

			local segItems = {
				{ id = "exercices", text = "Exercices" },
				{ id = "projets", text = "Projets" },
			}
			for i, s in ipairs(segItems) do
				local on = (s.id == sub)
				local cellW = 0.5
				local cellPos = UDim2.new(cellW * (i - 0.5), 0, 0.5, 0)
				local cellSize = UDim2.new(cellW, -2, 1, 0)

				-- Pastille active : le fond coloré est un FRÈRE du bouton, posé
				-- juste avant lui, et non son enfant — dans Roblox un enfant se
				-- dessine toujours AU-DESSUS de son parent, quel que soit le
				-- ZIndex, et le fond recouvrait donc le texte.
				if on then
					local fill = Instance.new("Frame")
					fill.BackgroundColor3 = C.accent
					fill.BorderSizePixel = 0
					fill.AnchorPoint = Vector2.new(0.5, 0.5)
					fill.Position = cellPos
					fill.Size = cellSize
					fill.ZIndex = 1
					fill.Parent = seg
					corner(fill, R.pill)
					gradient(fill, C.accent, C.accentDark, 90)
				end

				local b = Instance.new("TextButton")
				-- Fond transparent quand l'onglet est actif : la couleur vient
				-- du calque `fill`. Sans ça, un UIGradient sur le TextButton
				-- teinterait aussi son texte (le blanc virait au bleu délavé).
				b.BackgroundTransparency = 1
				b.BorderSizePixel = 0
				b.AutoButtonColor = false
				b.Text = s.text
				b.Font = on and Enum.Font.GothamBold or Enum.Font.GothamMedium
				b.TextSize = 12
				b.TextColor3 = on and C.onAccent or C.textSec
				b.AnchorPoint = Vector2.new(0.5, 0.5)
				b.Position = cellPos
				b.Size = cellSize
				b.ZIndex = 2
				b.Parent = seg
				corner(b, R.pill)
				if not on then
					b.MouseEnter:Connect(function() b.TextColor3 = C.textBright end)
					b.MouseLeave:Connect(function() b.TextColor3 = C.textSec end)
					b.MouseButton1Click:Connect(function()
						-- On mémorise le sous-onglet pour y revenir au prochain
						-- retour sur Apprendre.
						pcall(function() plugin:SetSetting("LB_last_sub", s.id) end)
						renderTabContent("apprendre", s.id)
					end)
				end
			end

			if sub == "projets" then
				renderProjets()
			else
				renderExercices()
			end

		elseif tabId == "bloxi" then
			renderBloxi()

		end
	end

	-- Exercices : la liste des modules jouables (sous-onglet d'Apprendre).
	renderExercices = function()
			-- Cache : au retour depuis un module, on réaffiche instantanément la
			-- liste connue puis on la rafraîchit en fond (pas de skeleton inutile).
			local cached = state.modulesCache
			if not cached then loadingState(contentContainer, myGen) end
			task.spawn(function()
				local modules = cached or api("GET", "/modules")
				if state.gen ~= myGen then return end
				if not modules then showError(nil, showModules); return end
				state.modulesCache = modules

				for _, ch in ipairs(contentContainer:GetChildren()) do
					if ch:IsA("GuiObject") and ch.Name ~= "LBSubTabs" then ch:Destroy() end
				end

				-- ── Progression globale (calculée depuis les données réelles) ──
				local totalMods, doneMods, totalEx, doneEx = 0, 0, 0, 0
				for _, m in ipairs(modules) do
					local ec = m.exerciseCount or 0
					local cc = math.min(m.completedCount or 0, ec)
					if ec > 0 then
						totalMods += 1
						totalEx += ec
						doneEx += cc
						if cc >= ec then doneMods += 1 end
					end
				end
				local pct = totalEx > 0 and math.floor(doneEx / totalEx * 100 + 0.5) or 0
				local allComplete = totalMods > 0 and doneMods >= totalMods

				-- Un module premium est hors de portée sans abonnement. Comme sur
				-- le site, il ne doit alors PAS bloquer les modules gratuits qui
				-- le suivent : le déblocage séquentiel l'enjambe.
				-- (Utilisé par « Reprendre » ET par les cartes de module.)
				local function isProLocked(m)
					if state.premium then return false end
					local lvl = m.level
					return lvl == "pro" or lvl == "build_pro" or lvl == "monetise_pro"
				end

				-- Où reprendre : premier module entamé mais non fini, sinon
				-- premier module accessible non commencé.
				local resumeMod, resumeIsStarted
				for _, m in ipairs(modules) do
					local ec = m.exerciseCount or 0
					if ec > 0 and not isProLocked(m) then
						local cc = math.min(m.completedCount or 0, ec)
						if cc > 0 and cc < ec then
							resumeMod, resumeIsStarted = m, true
							break -- un module en cours l'emporte toujours
						elseif not resumeMod and cc == 0 then
							resumeMod, resumeIsStarted = m, false
							-- on continue : un module entamé plus loin serait prioritaire
						end
					end
				end

				local exAction
				if resumeMod and not allComplete then
					local rDone = math.min(resumeMod.completedCount or 0, resumeMod.exerciseCount or 0)
					exAction = {
						kicker = resumeIsStarted and "REPRENDRE" or "COMMENCER",
						title = resumeMod.title,
						subtitle = resumeIsStarted
							and ("exercice " .. (rDone + 1) .. " sur " .. resumeMod.exerciseCount)
							or (resumeMod.exerciseCount .. " exercice"
								.. ((resumeMod.exerciseCount or 0) > 1 and "s" or "") .. " dans ce module"),
						badge = { play = true },
						onClick = function() showExercises(resumeMod.id, resumeMod.title, true) end,
					}
				end

				statusCard(contentContainer, {
					order = 1,
					color = allComplete and C.green or C.accent,
					ratio = totalEx > 0 and doneEx / totalEx or 0,
					title = allComplete and "Parcours terminé" or "Ta progression",
					subtitle = doneMods .. "/" .. totalMods .. " modules  ·  " .. doneEx .. "/" .. totalEx .. " exercices",
					action = exAction,
				})

				-- ── Titre de section ──
				-- Le libellé dit quel mode d'accès s'applique : sans ça, un
				-- parcours entièrement déverrouillé serait inexplicable.
				sectionTitle(contentContainer,
					state.moduleAccess == "free" and "PARCOURS LUAU · ACCÈS LIBRE" or "PARCOURS LUAU · PROGRESSIF",
					3, doneMods .. "/" .. totalMods)

				-- ── Cartes de modules (épurées) ──
				-- On n'affiche QUE les modules qui contiennent des exercices jouables
				-- dans le plugin. Les autres (théorie / quiz) restent sur le site.
				local shownCount = 0
				local prevModCompleted = true -- premier module toujours accessible
				for _, m in ipairs(modules) do
					local totalCount = m.exerciseCount or 0
					if totalCount == 0 then continue end
					shownCount += 1
					local doneCount = math.min(m.completedCount or 0, totalCount)
					local completed = doneCount >= totalCount
					local inProgress = doneCount > 0 and not completed
					local proLocked = isProLocked(m)

					-- Verrouillage séquentiel : doit terminer le module précédent.
					-- En mode « Accès libre » (préférence du site), rien n'est
					-- verrouillé : on affiche tous les modules ouverts.
					local locked = proLocked or (state.moduleAccess ~= "free"
						and not prevModCompleted and not completed and not inProgress)
					-- Un module premium inaccessible n'entre pas dans la chaîne :
					-- on laisse `prevModCompleted` tel quel pour l'enjamber.
					if not proLocked then prevModCompleted = completed end

					-- État visuel : terminé (vert) / en cours (accent) / premium
					-- (or, c'est une offre à débloquer) / verrouillé (atténué)
					local stateColor = completed and C.green
						or (inProgress and C.accent)
						or (proLocked and C.gold)
						or C.textMuted

					local row = Instance.new("TextButton")
					row.Text = ""
					row.AutoButtonColor = false
					row.BackgroundColor3 = C.card
					row.BackgroundTransparency = locked and 0.35 or 0
					row.BorderSizePixel = 0
					row.AutomaticSize = Enum.AutomaticSize.None
					row.Size = UDim2.new(1, 0, 0, inProgress and 68 or 60)
					row.LayoutOrder = shownCount + 3
					row.Parent = contentContainer
					corner(row, R.lg)
					local rowStroke = stroke(row, completed and C.green or C.border, 1)
					if completed then rowStroke.Transparency = 0.4 end
					pad(row, 13, 10, 13, 10)
					hlist(row, 11, Enum.VerticalAlignment.Center)

					-- Liseré d'accent à gauche pour le module en cours : repère
					-- visuel fort de "c'est ici que tu en es".
					if inProgress then
						local edge = Instance.new("Frame")
						edge.BackgroundColor3 = C.accent
						edge.BorderSizePixel = 0
						edge.Size = UDim2.new(0, 3, 0.62, 0)
						edge.AnchorPoint = Vector2.new(0, 0.5)
						edge.Position = UDim2.new(0, 0, 0.5, 0)
						edge.ZIndex = 2
						edge.Parent = row
						corner(edge, 2)
					end

					if not locked then
						row.MouseEnter:Connect(function()
							TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.cardHover }):Play()
							TweenService:Create(rowStroke, TweenInfo.new(0.12), { Color = completed and C.green or C.accent, Transparency = 0 }):Play()
						end)
						row.MouseLeave:Connect(function()
							TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.card }):Play()
							TweenService:Create(rowStroke, TweenInfo.new(0.12), { Color = completed and C.green or C.border, Transparency = completed and 0.4 or 0 }):Play()
						end)
						row.MouseButton1Click:Connect(function() showExercises(m.id, m.title) end)
					else
						row.MouseButton1Click:Connect(function()
							if proLocked then
								toast("Ce module fait partie de LearnBlox Premium. Débloque-le sur learnblox.fr.", "info")
							else
								toast("Termine le module précédent, ou passe en accès libre dans tes réglages sur learnblox.fr.", "info")
							end
						end)
					end

					-- Pastille : TOUJOURS le numéro du module (repère de position
					-- dans le parcours, quel que soit l'état). Ce qui change,
					-- c'est la couleur et le badge d'angle : coche si terminé,
					-- cadenas si verrouillé.
					local numTxt = tostring(m.id):match("%d+") or tostring(shownCount)
					local numBox = frame(row, {
						bg = completed and C.greenBg or (inProgress and C.accentBg or C.bgSurface),
						autoY = false, size = UDim2.new(0, 30, 0, 30), radius = R.md, order = 1,
						stroke = completed and C.green or (inProgress and C.accent or C.border), strokeThick = 1,
					})
					local nl = label(numBox, {
						text = numTxt, ts = 13, bold = true,
						color = completed and C.green or (inProgress and C.accent or C.textMuted),
						align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
					})
					nl.TextYAlignment = Enum.TextYAlignment.Center
					nl.TextWrapped = false

					if completed then
						tickBadge(numBox)
					elseif locked then
						-- Doré pour un module premium : c'est une offre, pas un
						-- simple « pas encore atteint ».
						lockBadge(numBox, proLocked and C.gold or nil)
					end

					-- Infos : titre + statut (+ mini-barre si en cours)
					local info = frame(row, { transparent = true, autoY = false, size = UDim2.new(1, -30 - 16 - 22, 1, 0), order = 2 })
					local iv = vlist(info, 4); iv.VerticalAlignment = Enum.VerticalAlignment.Center
					label(info, {
						text = m.title, ts = 13, bold = true,
						color = locked and C.textMuted or C.textBright, truncate = true, order = 1,
					})
					local subTxt
					if proLocked then
						subTxt = "Premium"
					elseif locked then
						subTxt = "Verrouillé"
					elseif completed then
						subTxt = "Terminé · " .. totalCount .. "/" .. totalCount
					elseif doneCount > 0 then
						subTxt = doneCount .. "/" .. totalCount .. " exercices"
					else
						subTxt = totalCount .. " exercice" .. (totalCount > 1 and "s" or "")
					end
					label(info, { text = subTxt, ts = 11, medium = true, color = stateColor, truncate = true, order = 2 })
					if inProgress then
						progressBar(info, doneCount / totalCount, C.accent, 3, 4)
					end

					-- Chevron (masqué si verrouillé : rien à ouvrir)
					if not locked then
						local chev = label(row, { text = "›", ts = 18, bold = true, color = C.textMuted, align = Enum.TextXAlignment.Center, size = UDim2.new(0, 16, 1, 0), order = 3 })
						chev.TextYAlignment = Enum.TextYAlignment.Center
						chev.TextWrapped = false
					end
				end

				-- Aucun module jouable : état vide illustré
				if shownCount == 0 then
					emptyState(contentContainer, "</>", "Aucun exercice disponible",
						"De nouveaux modules arrivent bientôt. En attendant, continue sur learnblox.fr.", C.accent, 3)
				else
					footnote(contentContainer, "Les modules de théorie et les quiz se poursuivent sur le site LearnBlox.", 900)
				end
			end)

	end

	-- Projets guidés : construire un jeu complet, étape par étape.
	renderProjets = function()
			loadingState(contentContainer, myGen)
			task.spawn(function()
				local projects = api("GET", "/projects")
				if state.gen ~= myGen then return end
				if not projects then showError(nil, function() showModules("projets") end); return end

				for _, ch in ipairs(contentContainer:GetChildren()) do
					if ch:IsA("GuiObject") and ch.Name ~= "LBSubTabs" then ch:Destroy() end
				end

				if #projects == 0 then
					emptyState(contentContainer, "[ ]", "Aucun projet pour l'instant",
						"Les projets fil rouge arrivent avec les prochains modules.", C.purple, 1)
					return
				end

				-- ── Agrégats ──
				-- On compte les ÉTAPES et pas seulement les projets terminés :
				-- un projet à 3 étapes sur 4 n'est pas « 0 », et l'afficher
				-- comme tel donnerait l'impression de n'avoir rien fait.
				-- `liveProj` = le projet ENTAMÉ mais pas fini (on reprend en
				-- priorité ce qu'on a laissé en plan) ; `nextProj` = le premier
				-- projet pas terminé, entamé ou non. Sans ce second cas, quand
				-- tous les projets commencés sont finis, le bouton disparaissait
				-- alors qu'il reste évidemment quelque chose à faire.
				local projDone, stepsDone, stepsTotal = 0, 0, 0
				local liveProj, nextProj = nil, nil
				for _, p in ipairs(projects) do
					local t = p.stepCount or 0
					local d = math.min(p.checkedCount or 0, t)
					local fini = p.completed or (t > 0 and d >= t)
					stepsTotal += t
					stepsDone += fini and t or d
					if fini then
						projDone += 1
					else
						if d > 0 and not liveProj then liveProj = p end
						if not nextProj then nextProj = p end
					end
				end
				-- Le projet à proposer : celui en cours, sinon le suivant.
				local resumeProj = liveProj or nextProj
				local pAllDone = #projects > 0 and projDone >= #projects

				local pjAction
				if resumeProj and not pAllDone then
					local lt = resumeProj.stepCount or 0
					local ld = math.min(resumeProj.checkedCount or 0, lt)
					local started = ld > 0
					pjAction = {
						kicker = started and "REPRENDRE" or "COMMENCER",
						title = resumeProj.projectTitle or "Projet",
						subtitle = started
							and ("étape " .. math.min(ld + 1, lt) .. " sur " .. lt)
							or (lt .. " étape" .. (lt > 1 and "s" or "") .. " à construire"),
						-- Le numéro de l'étape visée, pas un pictogramme : dans
						-- un projet, « où j'en suis » est l'information utile.
						badge = { text = tostring(math.min(ld + 1, math.max(lt, 1))) },
						onClick = function() showProject(resumeProj.moduleId, resumeProj.projectTitle) end,
					}
				end

				statusCard(contentContainer, {
					order = 1,
					color = pAllDone and C.green or C.purple,
					ratio = stepsTotal > 0 and stepsDone / stepsTotal or 0,
					title = pAllDone and "Tous les projets sont construits" or "Tes projets",
					subtitle = projDone .. "/" .. #projects .. " projets  ·  " .. stepsDone .. "/" .. stepsTotal .. " étapes",
					action = pjAction,
				})

				sectionTitle(contentContainer, "PROJETS GUIDÉS", 2, projDone .. "/" .. #projects)

				for idx, p in ipairs(projects) do
					local total = p.stepCount or 0
					local done = math.min(p.checkedCount or 0, total)
					local completed = p.completed or (total > 0 and done >= total)
					local inProgress = done > 0 and not completed

					-- Verrouillage séquentiel : le projet n'est accessible que si le précédent est terminé
					local prevCompleted = true
					if idx > 1 then
						local prev = projects[idx - 1]
						local prevTotal = prev.stepCount or 0
						local prevDone = math.min(prev.checkedCount or 0, prevTotal)
						prevCompleted = prev.completed or (prevTotal > 0 and prevDone >= prevTotal)
					end
					-- Comme pour les modules, le mode « Accès libre » lève tout
					-- verrouillage séquentiel.
					local locked = state.moduleAccess ~= "free"
						and not prevCompleted and not completed and not inProgress

					-- Les projets appartiennent au track "build" du site → violet.
					local trackColor = C.purple
					local stateColor = completed and C.green or (inProgress and trackColor or C.textMuted)

					local row = Instance.new("TextButton")
					row.Text = ""
					row.AutoButtonColor = false
					row.BackgroundColor3 = C.card
					row.BackgroundTransparency = locked and 0.35 or 0
					row.BorderSizePixel = 0
					row.AutomaticSize = Enum.AutomaticSize.None
					row.Size = UDim2.new(1, 0, 0, inProgress and 74 or 66)
					-- +2 : la carte de progression (1) et le titre de section (2)
					-- occupent déjà les deux premières places.
					row.LayoutOrder = idx + 2
					row.Parent = contentContainer
					corner(row, R.lg)
					local rowStroke = stroke(row, completed and C.green or C.border, 1)
					if completed then rowStroke.Transparency = 0.4 end
					pad(row, 13, 10, 13, 10)
					hlist(row, 11, Enum.VerticalAlignment.Center)

					if inProgress then
						local edge = Instance.new("Frame")
						edge.BackgroundColor3 = trackColor
						edge.BorderSizePixel = 0
						edge.Size = UDim2.new(0, 3, 0.62, 0)
						edge.AnchorPoint = Vector2.new(0, 0.5)
						edge.Position = UDim2.new(0, 0, 0.5, 0)
						edge.ZIndex = 2
						edge.Parent = row
						corner(edge, 2)
					end

					local card = row -- alias : la suite du rendu manipule `card`
					if not locked then
						row.MouseEnter:Connect(function()
							TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.cardHover }):Play()
							TweenService:Create(rowStroke, TweenInfo.new(0.12), { Color = completed and C.green or trackColor, Transparency = 0 }):Play()
						end)
						row.MouseLeave:Connect(function()
							TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.card }):Play()
							TweenService:Create(rowStroke, TweenInfo.new(0.12), { Color = completed and C.green or C.border, Transparency = completed and 0.4 or 0 }):Play()
						end)
						row.MouseButton1Click:Connect(function() showProject(p.moduleId, p.projectTitle) end)
					else
						row.MouseButton1Click:Connect(function()
							toast("Termine le projet précédent, ou passe en accès libre dans tes réglages sur learnblox.fr.", "info")
						end)
					end

					-- Pastille : toujours le numéro du module (même une fois le
					-- projet terminé), avec une coche en badge d'angle si validé.
					local numTxt = tostring(p.moduleId):match("%d+") or tostring(idx)
					local numBox = frame(card, {
						bg = completed and C.greenBg or (inProgress and C.purpleBg or C.bgSurface),
						autoY = false, size = UDim2.new(0, 30, 0, 30), radius = R.md, order = 1,
						stroke = completed and C.green or (inProgress and trackColor or C.border), strokeThick = 1,
					})
					local nl = label(numBox, {
						text = numTxt, ts = 13, bold = true,
						color = completed and C.green or (inProgress and trackColor or C.textMuted),
						align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
					})
					nl.TextYAlignment = Enum.TextYAlignment.Center
					nl.TextWrapped = false

					if completed then
						tickBadge(numBox)
					elseif locked then
						lockBadge(numBox)
					end

					local info = frame(card, { transparent = true, autoY = false, size = UDim2.new(1, -30 - 16 - 22, 1, 0), order = 2 })
					local iv = vlist(info, 4); iv.VerticalAlignment = Enum.VerticalAlignment.Center
					label(info, {
						text = p.projectTitle, ts = 13, bold = true,
						color = locked and C.textMuted or C.textBright, truncate = true, order = 1,
					})
					local sub
					if locked then
						sub = "Verrouillé"
					elseif completed then
						sub = "Terminé · " .. total .. "/" .. total .. " étapes"
					elseif done > 0 then
						sub = done .. "/" .. total .. " étapes"
					else
						sub = total .. " étape" .. (total > 1 and "s" or "") .. " à construire"
					end
					label(info, { text = sub, ts = 11, medium = true, color = stateColor, truncate = true, order = 2 })
					if inProgress then
						progressBar(info, total > 0 and done / total or 0, trackColor, 3, 4)
					end

					if not locked then
						local chev = label(card, { text = "›", ts = 18, bold = true, color = C.textMuted, align = Enum.TextXAlignment.Center, size = UDim2.new(0, 16, 1, 0), order = 3 })
						chev.TextYAlignment = Enum.TextYAlignment.Center
						chev.TextWrapped = false
					end
				end

				footnote(contentContainer, "Les projets se construisent dans Studio : suis les étapes, puis lance le jeu (F5) pour les valider automatiquement.", 900)
			end)

	end

	-- ── BLOXI : chat avec contexte Studio ──
	-- Bloxi LIT (sélection, script ouvert, erreurs) mais n'écrit rien de
	-- lui-même : chaque bloc de code porte ses boutons, et c'est l'utilisateur
	-- qui décide de l'insérer. Les actions automatiques viendront plus tard.
	renderBloxi = function()
		-- L'historique vit dans `state` : changer d'onglet ne doit pas effacer
		-- la conversation en cours.
		state.bloxiChat = state.bloxiChat or {}
		-- Zone de conversation : reconstruite à la main plutôt que via
		-- makeScroll, pour maîtriser la hauteur réservée en pied.
		contentContainer:Destroy()
		-- Empilement en pied sur cet onglet, de bas en haut :
		--   pilule d'onglets (à sa place habituelle) → barre de saisie →
		--   conversation. La saisie fait 74 px, plus 14 de séparation avec la
		--   pilule et 6 d'air sous la dernière bulle.
		local INPUT_RESERVE = 94
		local feed = Instance.new("ScrollingFrame")
		feed.Position = UDim2.new(0, 0, 0, HEADER_H)
		feed.Size = UDim2.new(1, 0, 1, -HEADER_H - TABBAR_H - INPUT_RESERVE)
		feed.BackgroundTransparency = 1
		feed.BorderSizePixel = 0
		feed.ScrollBarThickness = 2
		feed.ScrollBarImageColor3 = C.textMuted
		feed.ScrollBarImageTransparency = 0.6
		feed.ScrollingDirection = Enum.ScrollingDirection.Y
		feed.CanvasSize = UDim2.new(0, 0, 0, 0)
		feed.AutomaticCanvasSize = Enum.AutomaticSize.Y
		feed.Parent = root
		vlist(feed, 14)
		-- 22 px en bas : la dernière bulle ne doit pas toucher la pilule.
		pad(feed, 12, 14, 12, 22)
		contentContainer = feed

		local sending = false
		local ord = 0

		local function scrollToEnd()
			-- Différé de deux frames : AutomaticCanvasSize ne connaît la
			-- hauteur réelle qu'après le passage du layout.
			task.defer(function()
				task.defer(function()
					if not feed.Parent then return end
					feed.CanvasPosition = Vector2.new(0, math.max(0, feed.AbsoluteCanvasSize.Y))
				end)
			end)
		end

		-- ── Encart de code, avec ses actions ──
		-- Studio n'expose pas de presse-papier aux plugins (`setclipboard`
		-- n'existe pas ici) : « Insérer » est donc la seule action, et elle
		-- écrit directement dans le script. L'ancien bouton « Copier »
		-- révélait une zone à sélectionner à la main (Ctrl+A, Ctrl+C) —
		-- une manipulation pénible pour un résultat que l'insertion fait mieux.
		local function codeBlock(parent, code, order)
			local box = frame(parent, {
				bg = currentTheme == "dark" and C.bg or C.bgSurface,
				radius = R.md, order = order,
			})
			local bs = stroke(box, C.border); bs.Transparency = 0.45
			vlist(box, 0)

			-- En-tête : langage + actions
			local head = frame(box, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 30), order = 1 })
			pad(head, 10, 0, 6, 0)
			hlist(head, 5, Enum.VerticalAlignment.Center)

			-- -66 : la largeur du seul bouton restant (52) + les gouttières.
			-- Valait -118 quand « Copier » l'accompagnait.
			local lang = label(head, {
				text = "Luau", ts = 10, bold = true, color = C.textMuted,
				order = 1, size = UDim2.new(1, -66, 1, 0), truncate = true,
			})
			lang.TextYAlignment = Enum.TextYAlignment.Center

			-- Petit bouton d'action, taille contenue.
			local function miniBtn(txt, w, o, onClick)
				local b = Instance.new("TextButton")
				b.BackgroundColor3 = currentTheme == "dark" and C.elevated or C.card
				b.BorderSizePixel = 0
				b.AutoButtonColor = false
				b.Text = txt
				b.Font = Enum.Font.GothamMedium
				b.TextSize = 10
				b.TextColor3 = C.textSec
				b.Size = UDim2.new(0, w, 0, 21)
				b.LayoutOrder = o
				b.Parent = head
				corner(b, R["2xs"])
				local st = stroke(b, C.border); st.Transparency = 0.5
				b.MouseEnter:Connect(function()
					b.TextColor3 = C.accent; st.Color = C.accent; st.Transparency = 0.2
				end)
				b.MouseLeave:Connect(function()
					b.TextColor3 = C.textSec; st.Color = C.border; st.Transparency = 0.5
				end)
				b.MouseButton1Click:Connect(onClick)
				return b
			end

			miniBtn("Insérer", 52, 2, function()
				local target, doc = _resolveInsertTarget()
				if not target then
					toast("Ouvre un script dans l'éditeur (ou sélectionne-le dans l'Explorer), puis réessaie.", "info")
					return
				end
				local okRec, rec = pcall(function()
					return ChangeHistoryService:TryBeginRecording("Bloxi — insérer du code")
				end)
				local ok, where = _insertCodeAt(target, doc, code)
				if okRec and rec then
					pcall(function()
						ChangeHistoryService:FinishRecording(rec,
							ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel)
					end)
				end
				if ok then
					toast("Inséré " .. where .. " dans " .. target.Name .. " — Ctrl+Z pour annuler", "success")
				else
					toast("Insertion impossible dans ce script.", "error")
				end
			end)

			-- Filet sous l'en-tête
			local sep = frame(box, { bg = C.border, alpha = 0.5, autoY = false, size = UDim2.new(1, 0, 0, 1), order = 2 })

			-- Le code lui-même, colorisé comme dans l'éditeur de Studio.
			-- `rich = true` : highlightLuau produit des balises <font>, et il
			-- échappe < > & au passage (sinon `a < b` casserait le balisage).
			local body = frame(box, { transparent = true, order = 3 })
			pad(body, 10, 9, 10, 10)
			local okHL, painted = pcall(highlightLuau, code)
			local cl = label(body, {
				text = okHL and painted or code,
				rich = okHL or nil,
				ts = 11, color = C.text, lh = 1.4,
			})
			cl.Font = Enum.Font.Code
			cl.TextWrapped = true

			return box
		end

		-- ── Carte d'action (mode agent) ──
		-- Montre ce que Bloxi propose de faire, et n'agit qu'au clic. Le
		-- waypoint d'historique rend l'application annulable par Ctrl+Z.
		local function actionCard(parent, acts, order)
			-- Une suppression est irréversible côté contenu (Ctrl+Z la rattrape,
			-- mais l'utilisateur doit le savoir AVANT). La carte passe en rouge
			-- pour qu'on ne clique pas dessus par réflexe.
			local destructive = false
			for _, a in ipairs(acts) do
				if a.type == "delete" then destructive = true break end
			end
			local tone = destructive and C.red or C.accent

			local card2 = frame(parent, {
				bg = currentTheme == "dark" and C.bgSurface or (destructive and C.redBg or C.accentBg),
				radius = R.md, order = order,
			})
			local cs = stroke(card2, tone); cs.Transparency = 0.5
			pad(card2, 12, 11, 12, 11)
			vlist(card2, 9)

			label(card2, {
				text = destructive
					and (#acts == 1 and "Bloxi veut supprimer" or "Bloxi propose " .. #acts .. " actions, dont une suppression")
					or (#acts == 1 and "Bloxi propose une action"
						or ("Bloxi propose " .. #acts .. " actions")),
				ts = 11, bold = true, color = tone, order = 1,
			})

			-- Liste de ce qui va se passer : l'utilisateur doit pouvoir juger
			-- AVANT de cliquer, pas découvrir après.
			local list2 = frame(card2, { transparent = true, order = 2 })
			vlist(list2, 4)
			for i, a in ipairs(acts) do
				local what = tostring(a.label or a.type)
				local detail = ""
				if a.type == "create" and type(a.items) == "table" then
					detail = " · " .. #a.items .. " objet" .. (#a.items > 1 and "s" or "")
				elseif a.type == "build" then
					-- On annonce le volume : « construit dans le Workspace »
					-- ne dit pas si l'action pose 3 parts ou 300.
					local lignes = select(2, tostring(a.code or ""):gsub("\n", "")) + 1
					detail = " · construit dans Workspace (~" .. lignes .. " lignes)"
				elseif a.type == "script" or a.type == "modify" then
					detail = " · " .. tostring(a.target or "?")
				elseif a.type == "rename" then
					detail = " · " .. tostring(a.target or "?") .. " → " .. tostring(a.name or "?")
				elseif a.type == "delete" then
					-- Une suppression doit énumérer SES CIBLES : c'est la seule
					-- action irréversible côté contenu, l'utilisateur doit voir
					-- exactement ce qui part avant de cliquer.
					local list = {}
					if type(a.targets) == "table" then
						for _, t in ipairs(a.targets) do list[#list + 1] = tostring(t) end
					elseif a.target then
						list[1] = tostring(a.target)
					end
					detail = " · " .. (#list > 0 and table.concat(list, ", ") or "?")
				end
				local l = label(list2, {
					text = "· " .. what .. detail, ts = 10, medium = true,
					color = C.textSec, order = i,
				})
				l.TextWrapped = true
			end

			local btn2 = button(card2, {
				text = destructive and "Supprimer" or "Appliquer",
				size = UDim2.new(1, 0, 0, 30),
				variant = destructive and "danger" or "primary",
				ts = 11, radius = R.sm, order = 3,
			})
			local statusL
			btn2.MouseButton1Click:Connect(function()
				if not btn2.Parent then return end
				btn2.Active = false
				btn2.Text = "…"
				-- Un seul waypoint pour TOUT le bloc : Ctrl+Z annule
				-- l'ensemble, pas action par action.
				local okRec, rec = pcall(function()
					return ChangeHistoryService:TryBeginRecording("Bloxi — " ..
						(#acts == 1 and tostring(acts[1].label or "action") or (#acts .. " actions")))
				end)
				local done, msgs = 0, {}
				for _, a in ipairs(acts) do
					local ok, m = applyAction(a)
					if ok then done += 1 end
					msgs[#msgs + 1] = (ok and "✓ " or "✗ ") .. tostring(m)
				end
				if okRec and rec then
					pcall(function()
						ChangeHistoryService:FinishRecording(rec,
							done > 0 and Enum.FinishRecordingOperation.Commit
								or Enum.FinishRecordingOperation.Cancel)
					end)
				end

				btn2.Text = done == #acts and "Appliqué" or (done .. "/" .. #acts .. " appliqué")
				btn2.BackgroundColor3 = done > 0 and C.green or C.red
				if not statusL then
					statusL = label(card2, {
						text = "", ts = 9.5, color = C.textMuted, order = 4,
					})
					statusL.TextWrapped = true
				end
				statusL.Text = table.concat(msgs, "  ")
				toast(done > 0
					and (done .. " action" .. (done > 1 and "s appliquées" or " appliquée") .. " — Ctrl+Z pour annuler")
					or "Aucune action n'a pu être appliquée.",
					done > 0 and "success" or "error")
			end)
			return card2
		end

		-- ── Bulle de message ──
		local function bubble(who, text, order)
			local isUser = (who == "user")
			local isSys = (who == "system")
			local wrap = frame(feed, { transparent = true, order = order })

			-- L'utilisateur : bulle compacte alignée à droite, en accent.
			-- Bloxi : pleine largeur, sans fond de bulle — le texte respire et
			-- les encarts de code ont toute la place. C'est le pattern des
			-- assistants modernes, et il évite l'effet « boîte dans boîte ».
			if isUser then
				local b = frame(wrap, { bg = C.accent, radius = R.md, size = UDim2.new(0.85, 0, 0, 0) })
				b.AnchorPoint = Vector2.new(1, 0)
				b.Position = UDim2.new(1, 0, 0, 0)
				gradient(b, C.accent, C.accentDark, 135)
				pad(b, 12, 9, 12, 9)
				local t = label(b, { text = text, ts = 12, color = C.onAccent, lh = 1.35 })
				t.TextWrapped = true
				return wrap
			end

			if isSys then
				local b = frame(wrap, { bg = C.orangeBg, radius = R.md })
				local st = stroke(b, C.orange); st.Transparency = 0.45
				pad(b, 12, 10, 12, 10)
				local t = label(b, { text = text, ts = 11, color = C.orange, lh = 1.4 })
				t.TextWrapped = true
				return wrap
			end

			-- Réponse de Bloxi : en-tête discret + contenu.
			vlist(wrap, 7)
			local head = frame(wrap, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = 1 })
			hlist(head, 6, Enum.VerticalAlignment.Center)
			local av = frame(head, { bg = C.accent, autoY = false, size = UDim2.new(0, 18, 0, 18), radius = R.pill, order = 1 })
			local acv = frame(av, { transparent = true, autoY = false, size = UDim2.new(0, 18, 0, 18) })
			acv.AnchorPoint = Vector2.new(0.5, 0.5)
			acv.Position = UDim2.new(0.5, 0, 0.5, 0)
			local asc = Instance.new("UIScale"); asc.Scale = 0.52; asc.Parent = acv
			TAB_ICONS.bloxi(acv, C.onAccent)
			local nm = label(head, {
				text = "Bloxi", ts = 10, bold = true, color = C.textMuted,
				order = 2, size = UDim2.new(1, -24, 1, 0), truncate = true,
			})
			nm.TextYAlignment = Enum.TextYAlignment.Center

			local body = frame(wrap, { transparent = true, order = 2 })
			vlist(body, 9)

			-- Découpe : les blocs ```lua deviennent des encarts, le reste est
			-- du texte. Sans ça le code se noie dans le paragraphe.
			local segs = {}
			local rest = tostring(text or "")
			while true do
				local s, e, _, code = rest:find("```(%w*)\n?(.-)```")
				if not s then break end
				if s > 1 then segs[#segs + 1] = { kind = "text", v = rest:sub(1, s - 1) } end
				segs[#segs + 1] = { kind = "code", v = code }
				rest = rest:sub(e + 1)
			end
			if rest ~= "" then segs[#segs + 1] = { kind = "text", v = rest } end
			if #segs == 0 then segs[1] = { kind = "text", v = tostring(text or "") } end

			local o = 0
			for _, seg in ipairs(segs) do
				local v = seg.v:gsub("^%s+", ""):gsub("%s+$", "")
				-- Le TEXTE seulement : on normalise les blancs internes. Le
				-- modèle écrit ses paragraphes avec des retours ligne simples
				-- et parfois des doubles espaces ; rendus tels quels par
				-- Roblox, ils créaient des trous irréguliers au milieu des
				-- phrases. Un paragraphe = une ligne continue, c'est
				-- TextWrapped qui décide où couper.
				-- (Le code, lui, GARDE ses retours ligne : ils font le sens.)
				if seg.kind ~= "code" then
					v = v:gsub("\r\n", "\n")
						-- Deux marqueurs temporaires, protégés de l'aplatissement
						-- qui suit : \1 = saut de paragraphe, \2 = début de puce.
						:gsub("\n%s*\n", "\1")
						-- Une puce en début de ligne garde son retour, sinon
						-- toute la liste finirait sur une seule ligne.
						:gsub("\n%s*([%-•*])%s+", "\2%1 ")
						-- Tout le reste des retours ligne devient un espace :
						-- c'est TextWrapped qui décide où couper, pas le modèle.
						:gsub("%s*\n%s*", " ")
						:gsub("[ \t]+", " ")
						:gsub("\1", "\n\n")
						:gsub("\2", "\n")
				end
				if v ~= "" then
					o += 1
					if seg.kind == "code" then
						codeBlock(body, v, o)
					else
						-- Markdown minimal. L'échappement passe AVANT, sinon nos
						-- propres balises seraient neutralisées.
						-- La couleur du `code inline` est calculée ici : mise
						-- directement dans le motif de gsub, ses %d seraient
						-- pris pour des références de capture.
						local codeTint = string.format('<font color="rgb(%d,%d,%d)">',
							math.floor(C.accent.R * 255 + 0.5),
							math.floor(C.accent.G * 255 + 0.5),
							math.floor(C.accent.B * 255 + 0.5))
						local rich = v
							:gsub("&", "&amp;"):gsub("<", "&lt;"):gsub(">", "&gt;")
							:gsub("%*%*(.-)%*%*", "<b>%1</b>")
							:gsub("`(.-)`", codeTint .. "%1</font>")
						local tl = label(body, {
							text = rich, ts = 12, color = C.text, lh = 1.45, rich = true, order = o,
						})
						tl.TextWrapped = true
					end
				end
			end
			return wrap
		end

		-- Rejoue l'historique connu. Les réponses stockées contiennent les blocs
		-- <ACTION> bruts : on les retire à l'affichage (sinon du JSON
		-- apparaîtrait dans la bulle) et on remet leur carte.
		for _, m in ipairs(state.bloxiChat) do
			ord += 1
			if m.role == "user" then
				bubble("user", m.content, ord)
			else
				local text, acts = parseActions(m.content)
				bubble("bloxi", text ~= "" and text or "Voici ce que je propose :", ord)
				if #acts > 0 then
					ord += 1
					actionCard(feed, acts, ord)
				end
			end
		end

		-- ── Écran d'accueil ──
		-- Trois amorces cliquables : une zone de chat vide n'apprend rien, et
		-- personne ne devine ce que Bloxi sait faire ICI.
		local intro
		local submit -- défini plus bas, après la barre de saisie
		if #state.bloxiChat == 0 then
			-- Écran d'accueil centré dans la zone de conversation. Il est
			-- parenté à `root` et non au feed : un contenu centré dans un
			-- ScrollingFrame se cale sur le canvas (hauteur nulle ici), pas sur
			-- la zone visible — il se serait collé en haut.
			intro = frame(root, { transparent = true, autoY = false })
			intro.Name = "LBBloxiIntro"
			intro.Position = feed.Position
			intro.Size = feed.Size
			intro.ZIndex = 3
			local box = frame(intro, { transparent = true, autoY = false, size = UDim2.new(1, -44, 0, 0) })
			box.AutomaticSize = Enum.AutomaticSize.Y
			box.AnchorPoint = Vector2.new(0.5, 0.5)
			box.Position = UDim2.new(0.5, 0, 0.5, 0)
			local bl = vlist(box, 13)
			bl.HorizontalAlignment = Enum.HorizontalAlignment.Center

			-- Pastille : halo diffus derrière l'étoile, pour lui donner du
			-- corps sans alourdir.
			local halo = frame(box, { bg = C.accent, autoY = false, size = UDim2.new(0, 62, 0, 62), radius = R.pill, order = 1 })
			halo.BackgroundTransparency = 0.88
			local badge = frame(halo, { bg = C.accent, autoY = false, size = UDim2.new(0, 46, 0, 46), radius = R.pill })
			badge.AnchorPoint = Vector2.new(0.5, 0.5)
			badge.Position = UDim2.new(0.5, 0, 0.5, 0)
			gradient(badge, C.accent, C.accentDark, 135)
			local bcv = frame(badge, { transparent = true, autoY = false, size = UDim2.new(0, 18, 0, 18) })
			bcv.AnchorPoint = Vector2.new(0.5, 0.5)
			bcv.Position = UDim2.new(0.5, 0, 0.5, 0)
			local bsc = Instance.new("UIScale"); bsc.Scale = 1.25; bsc.Parent = bcv
			TAB_ICONS.bloxi(bcv, C.onAccent)

			local t1 = label(box, {
				text = "Je vois ton Studio", ts = 16, bold = true, color = C.textBright,
				align = Enum.TextXAlignment.Center, order = 2,
			})
			t1.TextWrapped = true
			local t2 = label(box, {
				text = "Pose ta question, le contexte part avec elle.",
				ts = 12, color = C.textSec, lh = 1.45,
				align = Enum.TextXAlignment.Center, order = 3,
			})
			t2.TextWrapped = true

			-- Ce que Bloxi voit, en trois lignes plutôt qu'en un paragraphe :
			-- une liste se parcourt d'un regard, un bloc de texte se lit.
			local list = frame(box, { transparent = true, order = 4, size = UDim2.new(1, -16, 0, 0) })
			local ll = vlist(list, 7)
			ll.HorizontalAlignment = Enum.HorizontalAlignment.Center
			for i, item in ipairs({
				{ "Ta sélection dans l'Explorer", C.blue },
				{ "Le script que tu as ouvert", C.purple },
				{ "Les erreurs de ton dernier test", C.orange },
			}) do
				-- UNE seule TextLabel par ligne, sans UIListLayout ni
				-- AutomaticSize : la combinaison des deux rognait les espaces
				-- du libellé (« Ta sélectiondans l'Explorer »). La puce est un
				-- caractère coloré en RichText, donc elle suit le texte au lieu
				-- d'être un élément à positionner.
				local row = frame(list, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = i })
				local col = item[2]
				local lb = label(row, {
					text = "", ts = 11, medium = true, color = C.textMuted,
					align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0), rich = true,
				})
				lb.Text = string.format('<font color="rgb(%d,%d,%d)">·</font>  %s',
					math.floor(col.R * 255 + 0.5), math.floor(col.G * 255 + 0.5), math.floor(col.B * 255 + 0.5),
					item[1])
				lb.TextWrapped = false
				lb.TextYAlignment = Enum.TextYAlignment.Center
			end
		end

		-- ══════════════════ BARRE DE SAISIE (bas d'écran) ══════════════════
		-- Toujours visible sur cet onglet : pas de bascule, pas de clic
		-- préalable. Deux rangées — le champ et son bouton d'envoi, puis une
		-- ligne d'options (mode Agent, compteur de messages).
		local INPUT_H = 74
		local barWrap = frame(root, {
			bg = C.card, autoY = false,
			size = UDim2.new(1, -16, 0, INPUT_H), radius = R.lg,
		})
		barWrap.Name = "LBBloxiInput"
		barWrap.AnchorPoint = Vector2.new(0.5, 1)
		-- Posée JUSTE AU-DESSUS de la pilule de navigation, qui garde sa place
		-- habituelle tout en bas. L'inverse (saisie sous la navigation) mettait
		-- le champ sous les onglets : on écrit au-dessus de ce qui navigue.
		barWrap.Position = UDim2.new(0.5, 0, 1, -TABBAR_H - 14)
		barWrap.ZIndex = 12 -- au-dessus de la pilule (10) et de son ombre (9)
		local barStroke = stroke(barWrap, C.border); barStroke.Transparency = 0.25
		pad(barWrap, 12, 9, 10, 9)
		vlist(barWrap, 8)

		-- Ombre : Studio n'a pas de box-shadow, on suggère l'élévation par une
		-- copie décalée derrière.
		local barShadow = Instance.new("Frame")
		barShadow.BackgroundColor3 = C.black
		barShadow.BackgroundTransparency = currentTheme == "dark" and 0.75 or 0.92
		barShadow.BorderSizePixel = 0
		barShadow.AnchorPoint = Vector2.new(0.5, 1)
		barShadow.Position = UDim2.new(0.5, 0, 1, -TABBAR_H - 11)
		barShadow.Size = UDim2.new(1, -10, 0, INPUT_H)
		barShadow.ZIndex = 11
		barShadow.Parent = root
		corner(barShadow, R.lg)

		-- ── Rangée 1 : champ + envoi ──
		local row1 = frame(barWrap, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 28), order = 1 })
		-- Le bouton d'envoi reste calé EN BAS : quand le champ grandit sur
		-- plusieurs lignes, il doit rester au niveau de la dernière ligne, pas
		-- flotter au milieu.
		hlist(row1, 8, Enum.VerticalAlignment.Bottom)

		local box = Instance.new("TextBox")
		box.BackgroundTransparency = 1
		box.Size = UDim2.new(1, -36, 1, 0)
		box.Font = Enum.Font.Gotham
		box.TextSize = 12
		box.TextColor3 = C.text
		-- Vidé EXPLICITEMENT : une TextBox neuve contient « TextBox », qui
		-- masquerait le placeholder.
		box.Text = ""
		box.PlaceholderText = "Demande-moi quelque chose…"
		box.PlaceholderColor3 = C.textMuted
		box.TextXAlignment = Enum.TextXAlignment.Left
		box.TextYAlignment = Enum.TextYAlignment.Top
		box.ClearTextOnFocus = false
		-- Multi-lignes : une question longue doit rester LISIBLE pendant
		-- qu'on la tape. Avec TextTruncate.AtEnd, elle disparaissait derrière
		-- des « … » dès qu'elle dépassait la largeur.
		box.MultiLine = true
		box.TextWrapped = true
		box.ZIndex = 11
		box.LayoutOrder = 1
		box.Parent = row1

		local send = Instance.new("TextButton")
		send.BackgroundColor3 = C.accent
		send.BorderSizePixel = 0
		send.AutoButtonColor = false
		send.Text = ""
		send.Size = UDim2.new(0, 28, 0, 28)
		send.ZIndex = 11
		send.LayoutOrder = 2
		send.Parent = row1
		corner(send, R.pill)
		-- Conservé pour pouvoir le RECOLORER au lieu d'en empiler un nouveau
		-- à chaque bascule envoi/arrêt (gradient() crée un UIGradient).
		local sendGrad = gradient(send, C.accent, C.accentDark, 135)
		-- Flèche dessinée : « ➤ » et « ↑ » tombent en tofu dans Studio.
		local arrow = frame(send, { transparent = true, autoY = false, size = UDim2.new(0, 13, 0, 13) })
		arrow.AnchorPoint = Vector2.new(0.5, 0.5)
		arrow.Position = UDim2.new(0.5, 0, 0.5, 0)
		arrow.ZIndex = 12
		local aParts = {}
		aParts[1] = ipx(arrow, 6.5, 7.5, 1.8, 10, C.onAccent, 1)        -- hampe
		aParts[2] = ipx(arrow, 4.1, 4.9, 1.8, 6, C.onAccent, 1, 45)     -- barbe gauche
		aParts[3] = ipx(arrow, 8.9, 4.9, 1.8, 6, C.onAccent, 1, -45)    -- barbe droite
		for _, p in ipairs(aParts) do p.ZIndex = 12 end
		-- Carré « stop » : pendant l'envoi, le bouton d'envoi DEVIENT le
		-- bouton d'arrêt (convention des interfaces de chat IA). Masqué au
		-- repos ; la flèche s'efface quand il apparaît.
		local stopSquare = frame(send, {
			bg = C.onAccent, autoY = false,
			size = UDim2.new(0, 9, 0, 9), radius = R.xs,
		})
		stopSquare.AnchorPoint = Vector2.new(0.5, 0.5)
		stopSquare.Position = UDim2.new(0.5, 0, 0.5, 0)
		stopSquare.ZIndex = 13
		stopSquare.Visible = false

		-- La barre grandit avec le texte, jusqu'à 3 lignes. Au-delà, le champ
		-- défile : une barre qui mange la moitié de l'écran serait pire que le
		-- problème qu'elle résout.
		local LINE_H = 15
		local MAX_LINES = 3
		local function fitInput()
			if not (box.Parent and barWrap.Parent) then return end
			-- TextBounds.Y donne la hauteur réelle du texte rendu, retours à la
			-- ligne compris.
			local lines = math.max(1, math.min(MAX_LINES,
				math.ceil((box.TextBounds.Y > 0 and box.TextBounds.Y or LINE_H) / LINE_H)))
			local h = 28 + (lines - 1) * LINE_H
			row1.Size = UDim2.new(1, 0, 0, h)
			barWrap.Size = UDim2.new(1, -16, 0, INPUT_H + (lines - 1) * LINE_H)
			barShadow.Size = UDim2.new(1, -10, 0, INPUT_H + (lines - 1) * LINE_H)
		end
		box:GetPropertyChangedSignal("TextBounds"):Connect(fitInput)
		box:GetPropertyChangedSignal("Text"):Connect(fitInput)

		-- ── Rangée 2 : options ──
		-- PAS de UIListLayout sur cette rangée : le bouton Agent est calé à
		-- gauche et le compteur à droite. Un layout en flux les collerait l'un
		-- à l'autre en ignorant leurs AnchorPoint.
		local row2 = frame(barWrap, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 20), order = 2 })

		-- Mode Agent : Bloxi propose des actions au lieu de seulement répondre.
		-- Réservé au Premium ET aux comptes dev — même règle que server.js, qui
		-- re-vérifie en base. Sans `state.dev` ici, un compte admin non marqué
		-- Premium voyait le bouton refuser de s'activer alors que le serveur,
		-- lui, l'aurait autorisé.
		local agentEligible = (state.premium == true) or (state.dev == true)
		local agent = Instance.new("TextButton")
		agent.BackgroundColor3 = C.accentBg
		agent.BackgroundTransparency = 1
		agent.BorderSizePixel = 0
		agent.AutoButtonColor = false
		agent.Text = ""
		agent.AnchorPoint = Vector2.new(0, 0.5)
		agent.Position = UDim2.new(0, 0, 0.5, 0)
		agent.Size = UDim2.new(0, agentEligible and 74 or 92, 0, 20)
		agent.ZIndex = 11
		agent.Parent = row2
		corner(agent, R.pill)
		local agentStroke = stroke(agent, C.border); agentStroke.Transparency = 0.45
		pad(agent, 7, 0, 8, 0)
		hlist(agent, 5, Enum.VerticalAlignment.Center)

		-- Pastille d'état : pleine quand le mode est actif.
		local agentDot = frame(agent, { bg = C.textMuted, autoY = false, size = UDim2.new(0, 6, 0, 6), radius = R.pill, order = 1 })
		agentDot.ZIndex = 12
		-- Sans Premium, le libellé porte la mention : le bouton dit ce qu'il
		-- coûte avant d'être cliqué, plutôt que de le révéler au clic.
		local agentLbl = label(agent, {
			text = agentEligible and "Agent" or "Agent · Pro", ts = 10, bold = true,
			color = agentEligible and C.textMuted or C.gold,
			order = 2, size = UDim2.new(0, agentEligible and 34 or 52, 1, 0),
		})
		agentLbl.TextYAlignment = Enum.TextYAlignment.Center
		agentLbl.TextWrapped = false
		agentLbl.ZIndex = 12
		if not agentEligible then agentDot.BackgroundColor3 = C.gold end

		-- Couleurs du bouton selon l'état. Déclarées ICI, avant setAgent qui
		-- les referme : plus bas, la closure les lirait à nil.
		--   tint = survol · rest = repos. Or pour un compte gratuit (c'est une
		--   offre), accent pour un Premium (c'est une bascule).
		local agentTint = agentEligible and C.accent or C.gold
		local agentRest = agentEligible and C.textMuted or C.gold

		-- Le mode Agent est LA distinction Premium : le chat est ouvert à tous
		-- (20 messages/jour), agir dans la place est le privilège. L'état est
		-- mémorisé entre les sessions.
		local function setAgent(on)
			state.agentMode = on
			agentDot.BackgroundColor3 = on and C.green or agentRest
			agentLbl.TextColor3 = on and C.green or agentRest
			agentStroke.Color = on and C.green or C.border
			agentStroke.Transparency = on and 0.2 or 0.45
			agent.BackgroundTransparency = on and 0.88 or 1
			agent.BackgroundColor3 = C.green
			pcall(function() plugin:SetSetting("LB_bloxi_agent", on) end)
		end
		do
			local okA, savedA = pcall(function() return plugin:GetSetting("LB_bloxi_agent") end)
			if okA and savedA == true and agentEligible then setAgent(true) end
		end

		agent.MouseButton1Click:Connect(function()
			if not agentEligible then
				toast("Le mode Agent est réservé au Pass Premium. Bloxi peut alors agir dans ta place, pas seulement répondre. Le chat, lui, reste ouvert à tous.", "info")
				return
			end
			local on = not state.agentMode
			setAgent(on)
			toast(on
				and "Mode Agent activé — Bloxi proposera des actions à appliquer d'un clic."
				or "Mode Agent désactivé — Bloxi se contente de répondre.", "info")
		end)
		-- Le survol ne doit PAS écraser l'état actif : un mode agent allumé
		-- reste vert, sinon on ne sait plus s'il est activé.
		agent.MouseEnter:Connect(function()
			if state.agentMode then return end
			agentStroke.Color = agentTint; agentStroke.Transparency = 0.2
			agentLbl.TextColor3 = agentTint
			agentDot.BackgroundColor3 = agentTint
		end)
		agent.MouseLeave:Connect(function()
			if state.agentMode then return end
			agentStroke.Color = C.border; agentStroke.Transparency = 0.45
			agentLbl.TextColor3 = agentRest
			agentDot.BackgroundColor3 = agentRest
		end)

		-- ── Export de la conversation (compte admin uniquement) ──
		-- Imprime tout l'échange dans l'Output de Studio, d'où il se copie au
		-- clavier. Les plugins n'ont pas accès au presse-papier : passer par
		-- l'Output est le seul chemin, et il a l'avantage de garder une trace
		-- consultable après coup.
		if state.dev then
			local dumpBtn = Instance.new("TextButton")
			dumpBtn.BackgroundTransparency = 1
			dumpBtn.BorderSizePixel = 0
			dumpBtn.AutoButtonColor = false
			dumpBtn.Text = "Exporter"
			dumpBtn.Font = Enum.Font.GothamMedium
			dumpBtn.TextSize = 10
			dumpBtn.TextColor3 = C.textMuted
			dumpBtn.AnchorPoint = Vector2.new(0, 0.5)
			dumpBtn.Position = UDim2.new(0, (state.premium and 74 or 92) + 8, 0.5, 0)
			dumpBtn.Size = UDim2.new(0, 54, 0, 20)
			dumpBtn.ZIndex = 11
			dumpBtn.Parent = row2
			corner(dumpBtn, R.pill)
			local dumpStroke = stroke(dumpBtn, C.border); dumpStroke.Transparency = 0.45
			dumpBtn.MouseEnter:Connect(function()
				dumpBtn.TextColor3 = C.accent
				dumpStroke.Color = C.accent; dumpStroke.Transparency = 0.2
			end)
			dumpBtn.MouseLeave:Connect(function()
				dumpBtn.TextColor3 = C.textMuted
				dumpStroke.Color = C.border; dumpStroke.Transparency = 0.45
			end)
			dumpBtn.MouseButton1Click:Connect(function()
				if #state.bloxiChat == 0 then
					toast("La conversation est vide.", "info")
					return
				end
				-- Un seul print par message : l'Output tronque les lignes très
				-- longues, et une conversation entière en un bloc deviendrait
				-- illisible autant qu'incopiable.
				print("")
				print("========== BLOXI — CONVERSATION ==========")
				print("Compte : " .. tostring(state.username or "?")
					.. "  ·  " .. os.date("%d/%m/%Y %H:%M")
					.. "  ·  " .. #state.bloxiChat .. " messages")
				-- Le contexte compte autant que l'échange pour comprendre une
				-- réponse à côté de la plaque.
				local ctx = collectStudioContext("")
				local vus = {}
				if ctx.selection then vus[#vus + 1] = #ctx.selection .. " objet(s) sélectionné(s)" end
				if ctx.scriptName then vus[#vus + 1] = "script « " .. ctx.scriptName .. " »" end
				if ctx.errors then vus[#vus + 1] = #ctx.errors .. " erreur(s)" end
				if ctx.inventory then vus[#vus + 1] = "inventaire (" .. #ctx.inventory .. " car.)" end
				print("Contexte au moment de l'export : "
					.. (#vus > 0 and table.concat(vus, ", ") or "aucun"))
				print("------------------------------------------")
				for i, m in ipairs(state.bloxiChat) do
					print("")
					print("[" .. i .. "] " .. (m.role == "user" and ">>> TOI" or "<<< BLOXI"))
					print(m.content)
				end
				print("")
				print("========== FIN ==========")
				toast("Conversation dans l'Output — sélectionne et Ctrl+C.", "success")
			end)
		end

		-- Compteur de messages, calé à droite. Dans un conteneur À PART et non
		-- dans le hlist de row2 : un UIListLayout place ses enfants à la suite
		-- et ignore leur AnchorPoint — le compteur se collait au bouton Agent.
		local counterWrap = Instance.new("Frame")
		counterWrap.BackgroundTransparency = 1
		counterWrap.AnchorPoint = Vector2.new(1, 0.5)
		counterWrap.Position = UDim2.new(1, 0, 0.5, 0)
		-- 104 et non 130 : avec le bouton « Exporter » du mode dev, la rangée
		-- débordait à la largeur minimale du panneau (300 px).
		counterWrap.Size = UDim2.new(0, 104, 1, 0)
		counterWrap.ZIndex = 11
		counterWrap.Parent = row2
		local counter = label(counterWrap, {
			text = "", ts = 10, bold = true, color = C.textMuted,
			align = Enum.TextXAlignment.Right, size = UDim2.new(1, 0, 1, 0),
		})
		counter.TextYAlignment = Enum.TextYAlignment.Center
		counter.TextWrapped = false
		counter.ZIndex = 12

		-- Quota épuisé : on bloque l'envoi CÔTÉ CLIENT aussi. Le serveur refuse
		-- déjà (et c'est lui qui fait autorité), mais laisser écrire puis
		-- répondre « plus de messages » fait perdre la question qu'on vient de
		-- taper. Mieux vaut le dire avant.
		local quotaSpent = false

		local function applyQuota(q)
			if not (counter.Parent and type(q) == "table") then return end
			local rem = tonumber(q.remaining)
			-- Messages offerts (bloxi_grant_messages / codes cadeaux). Ils ne
			-- sont entamés qu'APRÈS le quota du jour, mais ils comptent bel et
			-- bien : sans eux, un compte crédité voyait « 0/20 » et se croyait
			-- bloqué alors qu'il pouvait encore écrire.
			local bonus = tonumber(q.bonus) or 0

			quotaSpent = (not q.dev) and rem ~= nil and rem <= 0 and bonus <= 0
			if quotaSpent then
				box.PlaceholderText = "Plus de messages aujourd'hui — reviens demain"
				box.TextEditable = false
				send.BackgroundColor3 = C.textMuted
				for _, p in ipairs(aParts) do p.BackgroundTransparency = 0.45 end
			else
				box.PlaceholderText = "Demande-moi quelque chose…"
				box.TextEditable = true
				send.BackgroundColor3 = C.accent
				for _, p in ipairs(aParts) do p.BackgroundTransparency = 0 end
			end

			if q.dev then
				-- La base renvoie 9999/9999 pour un compte dev : un chiffre
				-- technique qui n'a pas à s'afficher tel quel.
				counter.Text = "messages illimités"
				counter.TextColor3 = C.textMuted
			elseif q.lifetime then
				-- Filet de sécurité : depuis la migration 20260827, plus aucun
				-- bucket ne se compte « à vie ». Un serveur pas encore migré
				-- peut cependant renvoyer ce champ.
				counter.Text = (rem or 0) .. " essai" .. ((rem or 0) > 1 and "s" or "") .. " restant" .. ((rem or 0) > 1 and "s" or "")
				counter.TextColor3 = (rem or 0) <= 1 and C.orange or C.textMuted
			elseif rem and rem <= 0 and bonus > 0 then
				-- Quota du jour épuisé, mais il reste des messages offerts :
				-- c'est eux qui servent maintenant.
				counter.Text = bonus .. " offert" .. (bonus > 1 and "s" or "")
				counter.TextColor3 = C.green
			elseif rem then
				counter.Text = rem .. "/" .. tostring(q.limit or "") .. " messages"
					.. (bonus > 0 and ("  +" .. bonus) or "")
				counter.TextColor3 = (rem <= 5 and bonus <= 0) and C.orange or C.textMuted
			end
		end

		task.spawn(function()
			local st = api("GET", "/bloxi/status")
			if type(st) == "table" and st.ok ~= false then applyQuota(st) end
		end)

		-- La barre s'éclaire au focus : on voit où on écrit.
		box.Focused:Connect(function()
			TweenService:Create(barStroke, TweenInfo.new(0.14), { Color = C.accent, Transparency = 0 }):Play()
		end)

		-- Fonction d'arrêt de la demande en cours. Réassignée à chaque envoi,
		-- remise à nil à la fin : le bouton STOP l'appelle sans connaître les
		-- détails de la requête.
		local cancelCurrent = nil

		local function setBusy(on)
			sending = on
			if not on then cancelCurrent = nil end
			-- Pendant l'envoi, le bouton devient un STOP rouge : c'est le même
			-- bouton, donc l'action d'arrêt est là où l'œil la cherche, sans
			-- ajouter d'élément flottant à côté du texte.
			send.BackgroundColor3 = on and C.red or C.accent
			sendGrad.Color = on
				and ColorSequence.new(C.red, C.red)
				or ColorSequence.new(C.accent, C.accentDark)
			stopSquare.Visible = on
			arrow.Visible = not on
			for _, p in ipairs(aParts) do
				p.BackgroundTransparency = 0
			end
		end

		submit = function(preset)
			if sending then return end
			if quotaSpent then
				toast("Tu as utilisé tes messages du jour. Ton quota se recharge dans 24 h.", "info")
				return
			end
			local msg = (preset or box.Text or ""):gsub("^%s+", ""):gsub("%s+$", "")
			if msg == "" then return end

			if intro and intro.Parent then intro:Destroy(); intro = nil end
			box.Text = ""
			setBusy(true)

			ord += 1
			bubble("user", msg, ord)
			state.bloxiChat[#state.bloxiChat + 1] = { role = "user", content = msg }

			-- ── La base locale d'abord ──
			-- Sur une question courante (« c'est quoi un RemoteEvent ? »), le
			-- plugin sait répondre seul : réponse instantanée, aucun message
			-- décompté, et ça marche même quand l'IA est saturée. On ne passe
			-- au serveur que pour ce qui demande vraiment de l'analyse.
			local locale = findQuickAnswer(msg)
			if locale then
				local corps = "**" .. locale.titre .. "**\n\n" .. locale.corps
				local fiche = locale.fiche and findCheatsheet(locale.fiche)
				if fiche then
					corps = corps .. "\n\n📄 Fiche complète : **" .. fiche.titre ..
						"** (" .. fiche.modules .. ") sur learnblox.fr/cheatsheets/" .. fiche.slug .. ".html"
				end
				corps = corps .. "\n\n-# Réponse locale du plugin — aucun message décompté. Repose ta question autrement si tu veux mon analyse de TON code."
				ord += 1
				bubble("bloxi", corps, ord)
				state.bloxiChat[#state.bloxiChat + 1] = { role = "assistant", content = corps }
				setBusy(false)
				scrollToEnd()
				return
			end

			-- Indicateur d'attente. Une construction en mode agent prend 1 à
			-- 2 minutes : trois points muets faisaient croire à un plantage.
			-- On affiche donc ce que Bloxi fait, le temps écoulé, et de quoi
			-- annuler.
			ord += 1
			local waitWrap = frame(feed, { transparent = true, order = ord })
			vlist(waitWrap, 6)

			local waitRow = frame(waitWrap, { transparent = true, order = 1 })
			hlist(waitRow, 6, Enum.VerticalAlignment.Center)
			local dots = {}
			for i = 1, 3 do
				local d = frame(waitRow, { bg = C.textMuted, autoY = false, size = UDim2.new(0, 5, 0, 5), radius = R.pill, order = i })
				d.BackgroundTransparency = 0.55
				dots[i] = d
			end
			-- Le libellé dépend de la demande : construire est long, répondre
			-- ne l'est pas. Annoncer la bonne attente évite l'impression de
			-- blocage.
			-- ⚠️ Les motifs Lua n'ont PAS d'alternance « | » : un seul motif
			-- avec des barres ne matche jamais. On teste donc chaque racine.
			local BUILD_HINTS = {
				"cr[ée]", "construi", "fabriqu", "g[ée]n[èe]r",
				"ajout", "plac", "dessin", "mod[ée]lis", "b[âa]ti",
			}
			local looksBuild = false
			if state.agentMode then
				local low = msg:lower()
				for _, p in ipairs(BUILD_HINTS) do
					if low:find(p) then looksBuild = true break end
				end
			end
			local waitLbl = label(waitRow, {
				text = looksBuild and "Bloxi construit…" or "Bloxi réfléchit…",
				ts = 11, color = C.textMuted, order = 4,
				size = UDim2.new(0, 150, 0, 16), truncate = true,
			})
			waitLbl.TextYAlignment = Enum.TextYAlignment.Center

			-- L'arrêt passe par le bouton d'envoi, devenu un STOP rouge (voir
			-- setBusy) : pas d'élément flottant à côté du texte, et l'action
			-- est là où l'œil la cherche.
			-- Drapeau LOCAL à cette demande. On ne touche pas à state.gen :
			-- l'incrémenter invaliderait la vue entière (newScreen s'en sert
			-- pour périmer les écrans), ce qui figerait l'onglet Bloxi.
			-- La requête HTTP, elle, n'est pas interruptible : on ignore sa
			-- réponse à l'arrivée.
			local cancelled = false
			cancelCurrent = function()
				if cancelled then return end
				cancelled = true
				if waitWrap and waitWrap.Parent then waitWrap:Destroy() end
				setBusy(false)
				ord += 1
				bubble("system", "Demande arrêtée.", ord)
				scrollToEnd()
			end

			task.spawn(function()
				local k, t0 = 0, os.clock()
				while waitWrap.Parent and sending and not cancelled do
					k += 1
					for i, d in ipairs(dots) do
						local on = ((k + i) % 3 == 0)
						TweenService:Create(d, TweenInfo.new(0.22), {
							BackgroundTransparency = on and 0 or 0.62,
						}):Play()
					end
					-- Chrono à partir de 5 s : en dessous il ajouterait du
					-- bruit, au-delà il prouve que ça avance encore.
					local el = math.floor(os.clock() - t0)
					if el >= 5 and waitLbl.Parent then
						local base = looksBuild and "Bloxi construit…" or "Bloxi réfléchit…"
						waitLbl.Text = base .. " " .. el .. "s"
					end
					task.wait(0.26)
				end
			end)
			scrollToEnd()

			task.spawn(function()
				local ctx = collectStudioContext(msg)
				-- 12 derniers messages, soit 6 échanges. À 8 (4 échanges), une
				-- session de débogage qui traîne sortait du contexte : Bloxi
				-- ne voyait plus ses propres réponses et reproposait à
				-- l'identique un correctif qui venait d'échouer.
				local hist = {}
				local from = math.max(1, #state.bloxiChat - 12)
				for i = from, #state.bloxiChat - 1 do hist[#hist + 1] = state.bloxiChat[i] end

				local data, status = apiRaw("POST", "/bloxi", {
					message = msg, history = hist, context = ctx,
					-- Le serveur RE-VÉRIFIE le statut Premium avant d'honorer
					-- ce drapeau : l'envoyer depuis un plugin modifié ne
					-- débloque rien.
					agent = state.agentMode == true,
				})

				if state.gen ~= myGen then return end
				-- Demande annulée pendant l'attente : la réponse arrive quand
				-- même (RequestAsync n'est pas interruptible), mais on ne
				-- l'affiche pas et on ne l'ajoute pas à l'historique.
				if cancelled then return end
				if waitWrap and waitWrap.Parent then waitWrap:Destroy() end
				setBusy(false)

				if not data then
					ord += 1
					bubble("system", status == 0
						and "Je n'arrive pas à joindre learnblox.fr. Vérifie ta connexion, puis réessaie."
						or ("Le serveur a répondu " .. tostring(status) .. ". Réessaie dans un instant."), ord)
					scrollToEnd()
					return
				end
				-- L'edge répond 200 même sur quota dépassé : le message utile
				-- est dans `error`, pas dans le code HTTP.
				if data.error or data.quotaExceeded then
					ord += 1
					bubble("system", data.error or "Tu as atteint ta limite de messages.", ord)
					-- Diagnostic en console : sans lui, un « je suis débordé »
					-- ne dit pas SI un modèle a été tenté, ni pourquoi il a
					-- échoué. Les logs de l'edge ne sont pas lisibles depuis
					-- Studio, donc c'est la seule trace exploitable.
					if type(data.routing) == "table" then
						local list = type(data.routing.providers) == "table"
							and table.concat(data.routing.providers, ", ") or ""
						warn("[LearnBlox] Bloxi — routage : agent="
							.. tostring(data.routing.agentMode)
							.. " · modèles tentés : "
							.. (list ~= "" and list or "AUCUN (clé manquante côté serveur)"))
					end
					if type(data.failures) == "table" and #data.failures > 0 then
						warn("[LearnBlox] Bloxi — aucun modèle n'a répondu : "
							.. table.concat(data.failures, " | "))
					elseif data.configError then
						warn("[LearnBlox] Bloxi — configuration serveur incomplète : "
							.. tostring(data.error))
					end
					scrollToEnd()
					return
				end

				local reply = data.reply or "…"
				-- Le serveur dit s'il a HONORÉ le mode agent. Un désaccord
				-- (activé ici, refusé là-bas) veut dire que le statut Premium
				-- n'est pas passé, ou que l'edge n'est pas à jour — sans ce
				-- message, on voyait juste Bloxi « ignorer » le mode.
				if state.agentMode and data.agent == false then
					ord += 1
					bubble("system", "Le mode Agent n'a pas été accepté par le serveur : vérifie ton Pass Premium, ou que la dernière version de Bloxi est déployée.", ord)
				end
				-- Quel modèle a RÉELLEMENT répondu. Sans cette trace, on juge
				-- au style de la réponse — peu fiable, et c'est exactement ce
				-- qui a fait douter que Claude était bien branché.
				if state.dev and data.provider then
					print("[LearnBlox] Bloxi — modèle : " .. tostring(data.provider)
						.. (type(data.failures) == "table" and #data.failures > 0
							and ("  · échecs : " .. table.concat(data.failures, ", ")) or ""))
				end
				-- Sépare le texte des blocs <ACTION> : le texte va en bulle,
				-- les actions dans une carte cliquable sous la réponse.
				local text, acts = parseActions(reply)
				ord += 1
				bubble("bloxi", text ~= "" and text or "Voici ce que je propose :", ord)
				if #acts > 0 then
					ord += 1
					actionCard(feed, acts, ord)
				end
				-- L'historique garde la réponse BRUTE (actions comprises) : si
				-- l'utilisateur enchaîne, le modèle doit savoir ce qu'il a
				-- proposé, pas seulement le texte qui l'entourait.
				state.bloxiChat[#state.bloxiChat + 1] = { role = "assistant", content = reply }
				-- Le compteur vient de la réponse elle-même : l'edge renvoie le
				-- quota consommé, inutile de refaire un appel réseau pour ça.
				-- Une seule fonction pour l'affichage du quota : la dupliquer ici
				-- l'avait déjà fait diverger (le bonus manquait des deux côtés).
				if type(data.quota) == "table" then applyQuota(data.quota) end
				scrollToEnd()
			end)
		end

		-- Le même bouton envoie ou arrête, selon l'état : c'est un STOP rouge
		-- pendant la génération (voir setBusy), et la flèche d'envoi sinon.
		send.MouseButton1Click:Connect(function()
			if sending then
				if cancelCurrent then cancelCurrent() end
				return
			end
			submit()
		end)
		box.FocusLost:Connect(function(enterPressed)
			TweenService:Create(barStroke, TweenInfo.new(0.14), { Color = C.border, Transparency = 0.25 }):Play()
			if enterPressed then
				submit()
				-- On rend le focus au champ : on enchaîne souvent plusieurs
				-- questions d'affilée.
				task.defer(function()
					if box.Parent then pcall(function() box:CaptureFocus() end) end
				end)
			end
		end)

		scrollToEnd()
	end

	-- Pilule de navigation, toujours au même endroit quel que soit l'onglet.
	-- Sur Bloxi, c'est la barre de saisie qui vient se poser au-dessus d'elle.
	makeTabBar(root, tabLabels, activeTab, function(id)
		if id == activeTab then return end
		pcall(function() plugin:SetSetting("LB_last_tab", id) end)
		showModules(id)
	end)

	-- La veille du script (bootstrap) doit pouvoir rafraîchir la conversation
	-- après y avoir ajouté une remarque. `renderBloxi` est local à init() :
	-- on expose un rappel, qui ne redessine que si l'onglet Bloxi est ouvert.
	_refreshBloxiFeed = function()
		if activeTab ~= "bloxi" then return end
		pcall(function() renderBloxi() end)
	end

	renderTabContent(activeTab, initialSub)
end

-- ── EXERCICES D'UN MODULE ──
-- `autoOpenNext` : venu du bouton « Reprendre », on saute la liste et on
-- ouvre directement le premier exercice non validé.
showExercises = function(moduleId, moduleTitle, autoOpenNext)
	state.currentModule = { id = moduleId, title = moduleTitle }
	local root, myGen = newScreen()
	clearExerciseCtx(); clearProjectCtx() -- liste d'exercices : rien d'"actif" précis

	makeNavBar(root, {
		eyebrow = "MODULE",
		title = moduleTitle,
		onBack = function() showModules("exercices") end,
	})

	local scroll = makeScroll(root, NAV_H)
	loadingState(scroll, myGen)

	task.spawn(function()
		local exercises = api("GET", "/exercises?moduleId=" .. moduleId)
		if state.gen ~= myGen then return end
		if not exercises then showError(nil, function() showExercises(moduleId, moduleTitle) end); return end
		state.exercises = exercises

		-- « Reprendre » : on va droit au premier exercice non validé. Si le
		-- module est en fait terminé, on retombe sur la liste (rien à ouvrir).
		if autoOpenNext then
			for i, ex in ipairs(exercises) do
				if not ex.completed then
					showExercise(ex, i)
					return
				end
			end
		end

		for _, c in ipairs(scroll:GetChildren()) do
			if c:IsA("GuiObject") then c:Destroy() end
		end

		-- barre de progression
		local doneCount = 0
		for _, ex in ipairs(exercises) do if ex.completed then doneCount += 1 end end
		local total = #exercises
		local allDone = total > 0 and doneCount == total
		local ratio = total > 0 and doneCount / total or 0

		local progColor = allDone and C.green or C.accent
		local progCard = card(scroll, { bg = allDone and C.greenBg or C.card, stroke = allDone and C.green or C.border, order = 1 })
		pad(progCard, 14, 13, 14, 13)
		vlist(progCard, 9)
		local prow = frame(progCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = 1 })
		hlist(prow, 0, Enum.VerticalAlignment.Center)
		local prowTitle = label(prow, {
			text = allDone and "Module terminé, bravo !" or "Progression du module",
			ts = 12, bold = true, color = allDone and C.green or C.textBright,
			size = UDim2.new(1, -54, 1, 0), order = 1, truncate = true,
		})
		prowTitle.TextYAlignment = Enum.TextYAlignment.Center
		local prowCount = label(prow, {
			text = doneCount .. "/" .. total, ts = 12, bold = true, color = progColor,
			align = Enum.TextXAlignment.Right, size = UDim2.new(0, 54, 1, 0), order = 2,
		})
		prowCount.TextYAlignment = Enum.TextYAlignment.Center
		progressBar(progCard, ratio, progColor, 2, 7)

		-- Repère le prochain exercice à faire : il reçoit une mise en avant.
		local nextIdx
		for i, ex in ipairs(exercises) do
			if not ex.completed then nextIdx = i; break end
		end

		-- liste exercices
		for i, ex in ipairs(exercises) do
			local isNext = (i == nextIdx)
			local row = Instance.new("TextButton")
			row.Text = ""
			row.AutoButtonColor = false
			row.BackgroundColor3 = C.card
			row.BorderSizePixel = 0
			row.AutomaticSize = Enum.AutomaticSize.None
			row.Size = UDim2.new(1, 0, 0, 66)
			row.LayoutOrder = i + 1
			row.Parent = scroll
			corner(row, R.lg)
			local rowStroke = stroke(row, ex.completed and C.green or (isNext and C.accent or C.border), isNext and 1.5 or 1)
			if ex.completed then rowStroke.Transparency = 0.45 end
			pad(row, 12, 10, 12, 10)
			hlist(row, 11, Enum.VerticalAlignment.Center)
			row.MouseEnter:Connect(function()
				TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.cardHover }):Play()
			end)
			row.MouseLeave:Connect(function()
				TweenService:Create(row, TweenInfo.new(0.12), { BackgroundColor3 = C.card }):Play()
			end)
			row.MouseButton1Click:Connect(function() showExercise(ex, i) end)

			-- Statut : le numéro de l'exercice reste toujours lisible ; la
			-- réussite se lit à la couleur + la coche en badge d'angle.
			local statusBox = frame(row, {
				bg = ex.completed and C.greenBg or (isNext and C.accent or C.bgSurface),
				autoY = false, size = UDim2.new(0, 32, 0, 32), radius = R.md, order = 1,
				stroke = ex.completed and C.green or (isNext and C.accent or C.border), strokeThick = 1,
			})
			local sl = label(statusBox, {
				text = tostring(i), ts = 14, bold = true,
				color = ex.completed and C.green or (isNext and C.white or C.textMuted),
				align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
			})
			sl.TextYAlignment = Enum.TextYAlignment.Center
			sl.TextWrapped = false
			if ex.completed then tickBadge(statusBox) end

			-- infos : titre + badges (difficulté, points, « à faire »)
			local info = frame(row, { transparent = true, autoY = false, size = UDim2.new(1, -32 - 16 - 22, 1, 0), order = 2 })
			local iv = vlist(info, 5); iv.VerticalAlignment = Enum.VerticalAlignment.Center
			label(info, {
				text = ex.title, ts = 13, bold = true,
				color = ex.completed and C.textSec or C.textBright, truncate = true, order = 1,
			})
			local badges = frame(info, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = 2 })
			hlist(badges, 5)
			local dc = diffColor(ex.difficulty)
			pill(badges, ex.difficulty, C.card:Lerp(dc, currentTheme == "dark" and 0.18 or 0.1), dc, 1)
			pill(badges, "+" .. (ex.points or 15) .. " pts", C.bgSurface, C.textSec, 2, { stroke = C.border })
			if isNext then
				pill(badges, "À FAIRE", C.accent, C.onAccent, 3)
			end

			local chev = label(row, { text = "›", ts = 18, bold = true, color = C.textMuted, align = Enum.TextXAlignment.Center, size = UDim2.new(0, 16, 1, 0), order = 3 })
			chev.TextYAlignment = Enum.TextYAlignment.Center
			chev.TextWrapped = false
		end

		if total == 0 then
			emptyState(scroll, "</>", "Aucun exercice ici",
				"Ce module n'a pas encore d'exercice jouable dans Studio.", C.accent, 99)
		end
	end)
end

-- ── DÉTAIL EXERCICE ──
showExercise = function(exercise, index)
	local root, myGen = newScreen()
	clearProjectCtx() -- on est sur un exercice, pas sur le projet


	-- mémorise le contexte pour restaurer cet exercice après un playtest
	pcall(function()
		plugin:SetSetting(SETTING_CTX, {
			moduleId = state.currentModule and state.currentModule.id,
			moduleTitle = state.currentModule and state.currentModule.title,
			exId = exercise.id,
			-- Titre de l'exercice : Bloxi s'en sert pour savoir sur QUOI il ne
			-- doit pas donner la solution.
			exTitle = exercise.title,
			index = index,
		})
	end)

	-- ── barre de nav : retour au module + navigation entre exercices ──
	local navTotal = #state.exercises
	local _, navRowBar = makeNavBar(root, {
		eyebrow = "EXERCICE " .. index .. " / " .. navTotal,
		title = state.currentModule and state.currentModule.title or "Retour",
		trailingWidth = 62,
		onBack = function() showExercises(state.currentModule.id, state.currentModule.title) end,
	})

	-- Flèches précédent / suivant : on saute d'un exercice à l'autre sans
	-- repasser par la liste (le geste le plus fréquent de l'apprenant).
	local function navTo(delta)
		local target = index + delta
		local ex = state.exercises[target]
		if ex then showExercise(ex, target) end
	end
	local prevEx, nextExItem = state.exercises[index - 1], state.exercises[index + 1]
	local navBox = frame(navRowBar, { transparent = true, autoY = false, size = UDim2.new(0, 58, 1, 0), order = 3 })
	local nbl = hlist(navBox, 2); nbl.VerticalAlignment = Enum.VerticalAlignment.Center
	nbl.HorizontalAlignment = Enum.HorizontalAlignment.Right
	for _, spec in ipairs({ { "‹", -1, prevEx }, { "›", 1, nextExItem } }) do
		local glyph, delta, available = spec[1], spec[2], spec[3]
		local b = button(navBox, {
			text = glyph, size = UDim2.new(0, 26, 0, 26), variant = "ghost",
			tc = available and C.textSec or C.textMuted, ts = 17, radius = R.sm,
			order = delta < 0 and 1 or 2,
			onClick = available and function() navTo(delta) end or nil,
		})
		b.TextWrapped = false
		b.TextTransparency = available and 0 or 0.6
		if available then
			b.MouseEnter:Connect(function() b.TextColor3 = C.accent end)
			b.MouseLeave:Connect(function() b.TextColor3 = C.textSec end)
		end
	end

	local scroll = makeScroll(root, NAV_H)

	-- ── titre + badges ──
	local titleCard = card(scroll, { order = 1, stroke = exercise.completed and C.green or C.border })
	pad(titleCard, 15, 14, 15, 14)
	vlist(titleCard, 9)
	local badges = frame(titleCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 20), order = 1 })
	hlist(badges, 5)
	local dc = diffColor(exercise.difficulty)
	pill(badges, exercise.difficulty, C.card:Lerp(dc, currentTheme == "dark" and 0.18 or 0.1), dc, 1)
	pill(badges, "+" .. (exercise.points or 15) .. " pts", C.accentBg, C.accent, 2)
	if exercise.completed then pill(badges, "✓ Validé", C.green, C.white, 3) end
	label(titleCard, { text = exercise.title, ts = 17, bold = true, color = C.textBright, lh = 1.15, order = 2 })

	-- ── consigne ──
	-- Carte accentuée avec liseré à gauche : c'est le contenu que l'élève
	-- relit le plus, il doit ressortir immédiatement.
	local instrCard = frame(scroll, { bg = C.accentBg, radius = R.lg, order = 2 })
	pad(instrCard, 15, 13, 15, 13)
	vlist(instrCard, 7)
	local instrHead = frame(instrCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 14), order = 1 })
	hlist(instrHead, 6, Enum.VerticalAlignment.Center)
	local ih = label(instrHead, { text = "CONSIGNE", ts = 10, bold = true, color = C.accent, size = UDim2.new(1, 0, 1, 0), order = 1 })
	ih.TextYAlignment = Enum.TextYAlignment.Center
	label(instrCard, { text = exercise.instructions or "Pas d'instructions.", ts = 12, color = C.text, lh = 1.3, order = 2 })

	-- ── diagnostic du dernier plantage ──
	-- Traduit l'erreur Luau captée au playtest. On nomme le problème et on
	-- donne une piste, jamais la correction : l'élève cherche lui-même.
	local diag = state.lastDiag
	if diag and diag.exId == exercise.id and not exercise.completed then
		local diagCard = frame(scroll, { bg = C.redBg, radius = R.lg, order = 3, stroke = C.red })
		pad(diagCard, 14, 13, 14, 13)
		vlist(diagCard, 9)

		local dHead = frame(diagCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 16), order = 1 })
		hlist(dHead, 7, Enum.VerticalAlignment.Center)
		local dBadge = frame(dHead, { bg = C.red, autoY = false, size = UDim2.new(0, 16, 0, 16), radius = R.pill, order = 1 })
		local dbl = label(dBadge, { text = "!", ts = 11, bold = true, color = C.white, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
		dbl.TextYAlignment = Enum.TextYAlignment.Center
		dbl.TextWrapped = false
		local dTitle = label(dHead, {
			text = diag.line and ("TON CODE A PLANTÉ · LIGNE " .. diag.line) or "TON CODE A PLANTÉ",
			ts = 10, bold = true, color = C.red, size = UDim2.new(1, -50, 1, 0), order = 2, truncate = true,
		})
		dTitle.TextYAlignment = Enum.TextYAlignment.Center

		-- Fermeture : l'élève peut écarter le diagnostic quand il l'a lu
		local dClose = button(dHead, {
			text = "×", size = UDim2.new(0, 20, 0, 20), variant = "ghost",
			tc = C.red, ts = 15, radius = R.sm, order = 3,
			onClick = function()
				state.lastDiag = nil
				diagCard:Destroy()
			end,
		})
		dClose.TextWrapped = false

		label(diagCard, { text = diag.what, ts = 12, medium = true, color = C.textBright, lh = 1.3, order = 2 })
		if diag.fix and diag.fix ~= "" then
			local fixBox = frame(diagCard, { bg = C.bgSurface, radius = R.sm, order = 3 })
			pad(fixBox, 11, 9, 11, 9)
			vlist(fixBox, 4)
			label(fixBox, { text = "PAR OÙ COMMENCER", ts = 9, bold = true, color = C.textMuted, order = 1 })
			label(fixBox, { text = diag.fix, ts = 11, color = C.textSec, lh = 1.3, order = 2 })
		end

		-- Aller droit à la ligne fautive
		button(diagCard, {
			text = diag.line and ("Ouvrir le script à la ligne " .. diag.line) or "Ouvrir le script",
			size = UDim2.new(1, 0, 0, 34), variant = "secondary", ts = 12, radius = R.sm, order = 4,
			onClick = function()
				local folder = ServerScriptService:FindFirstChild("LearnBlox")
				local s = folder and folder:FindFirstChild("LearnBlox_" .. (state.currentModule and state.currentModule.id or "mod") .. "_" .. exercise.id)
				if not s then toast("Script introuvable. Rouvre-le depuis le bouton ci-dessous.", "error"); return end
				-- OpenScript accepte une ligne : le curseur s'y place directement
				if diag.line then plugin:OpenScript(s, diag.line) else plugin:OpenScript(s) end
			end,
		})

	end

	-- ── helpers script ──
	-- Le nom inclut le module ET l'exercice : les IDs d'exercice (ex_1, ex_2…)
	-- sont réutilisés d'un module à l'autre, donc sans le module deux exercices
	-- différents partageraient le même script.
	-- Les scripts sont rangés dans un dossier "LearnBlox" dans ServerScriptService.
	local scriptName = "LearnBlox_" .. (state.currentModule and state.currentModule.id or "mod") .. "_" .. exercise.id

	local function getFolder()
		local folder = ServerScriptService:FindFirstChild("LearnBlox")
		if not folder then
			folder = Instance.new("Folder")
			folder.Name = "LearnBlox"
			folder.Parent = ServerScriptService
		end
		return folder
	end

	local function getScript() return getFolder():FindFirstChild(scriptName) end
	local function ensureScript()
		local folder = getFolder()
		local s = folder:FindFirstChild(scriptName)
		if not s then
			s = Instance.new("Script")
			s.Name = scriptName
			s.Source = exercise.starterCode or "-- Écris ton code ici\n"
			s.Parent = folder
		end
		s.Disabled = false -- l'exercice en cours doit pouvoir s'exécuter au Play
		return s
	end

	-- ── résultat de validation ──
	-- La validation se fait pendant le playtest (F5) : le serveur de jeu valide
	-- et écrit le résultat dans la vraie console Roblox + dans les settings.
	-- Ici, l'UI Edit ne fait que REFLÉTER ce résultat.
	local function handleResult()
		local session = select(2, pcall(function() return plugin:GetSetting(SETTING_SESSION) end))
		local seen = select(2, pcall(function() return plugin:GetSetting(SETTING_SEEN) end))
		if not session or session == seen then return end
		local result = select(2, pcall(function() return plugin:GetSetting(SETTING_RESULT) end))
		local curMod = state.currentModule and state.currentModule.id
		if type(result) ~= "table" or result.exId ~= exercise.id or result.moduleId ~= curMod then return end
		pcall(function() plugin:SetSetting(SETTING_SEEN, session) end)
		if result.success then
			-- `points` fait foi : 0 = déjà crédité (site ou Studio). Ne jamais
			-- retomber sur un « +15 » par défaut, qui annoncerait des points
			-- que le serveur n'a pas versés.
			local pts = tonumber(result.points) or 0
			toast(
				(result.alreadyCompleted or pts <= 0) and "Déjà validé"
					or ("Bravo ! +" .. pts .. " pts"),
				"success"
			)
			state.lastDiag = nil -- le code tourne : le diagnostic n'a plus lieu d'être
			exercise.completed = true
			if state.exercises[index] then state.exercises[index].completed = true end
			-- La progression globale a changé : le prochain retour à l'accueil
			-- doit refléter le nouveau compteur, pas la version en cache.
			state.modulesCache = nil
			task.wait(0.4)
			if state.gen == myGen then showExercise(exercise, index) end
		elseif result.diagWhat then
			-- Le code a planté : on garde le diagnostic à l'écran (une carte,
			-- pas un toast qui disparaît) et on redessine pour l'afficher.
			state.lastDiag = {
				exId = exercise.id,
				what = result.diagWhat,
				fix = result.diagFix,
				line = result.diagLine,
			}
			task.wait(0.2)
			if state.gen == myGen then showExercise(exercise, index) end
		else
			toast("Pas encore : " .. (result.error or "Pas encore réussi — regarde la console du jeu."), "error")
		end
	end

	-- Secours : réinterroge le serveur si le résultat n'a pas été capté
	local function refreshStatus()
		toast("Actualisation…", "info")
		task.spawn(function()
			local exs = api("GET", "/exercises?moduleId=" .. state.currentModule.id)
			if state.gen ~= myGen or type(exs) ~= "table" then return end
			for _, ex in ipairs(exs) do
				if ex.id == exercise.id then
					if ex.completed and not exercise.completed then
						exercise.completed = true
						if state.exercises[index] then state.exercises[index].completed = true end
						state.modulesCache = nil
						showExercise(exercise, index)
					else
						toast("Aucun changement pour l'instant.", "info")
					end
					return
				end
			end
		end)
	end

	-- traite un résultat déjà présent (cas où l'UI se recharge après l'arrêt du jeu)
	handleResult()

	-- watcher : détecte un nouveau résultat de partie
	task.spawn(function()
		while state.gen == myGen and scroll and scroll.Parent do
			task.wait(1)
			handleResult()
		end
	end)

	-- ── actions ──
	local actCard = card(scroll, { order = 4 })
	pad(actCard, 14, 14, 14, 14)
	vlist(actCard, 9)

	if exercise.completed then
		-- Désactive le script validé pour qu'il ne s'exécute plus au Play et ne
		-- vienne pas polluer la sortie des prochains exercices.
		local doneScript = getScript()
		if doneScript and not doneScript.Disabled then doneScript.Disabled = true end

		-- Bandeau de réussite : pastille verte + message + note technique
		local banner = frame(actCard, { bg = C.greenBg, radius = R.md, order = 1 })
		pad(banner, 12, 11, 12, 11)
		hlist(banner, 10, Enum.VerticalAlignment.Top)
		local checkBox = frame(banner, { bg = C.green, autoY = false, size = UDim2.new(0, 26, 0, 26), radius = R.pill, order = 1 })
		local ck = label(checkBox, { text = "✓", ts = 15, bold = true, color = C.white, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
		ck.TextYAlignment = Enum.TextYAlignment.Center
		ck.TextWrapped = false
		local bInfo = frame(banner, { transparent = true, size = UDim2.new(1, -36, 0, 0), order = 2 })
		vlist(bInfo, 3)
		label(bInfo, { text = "Exercice validé, beau travail !", ts = 13, bold = true, color = C.green, order = 1 })
		label(bInfo, { text = "Le script a été désactivé pour ne pas gêner les prochains exercices.", ts = 11, color = C.textSec, order = 2 })

		local nextIdx = index + 1
		if nextIdx <= #state.exercises then
			local nextEx = state.exercises[nextIdx]
			-- CTA : le titre du prochain exercice, pour savoir où l'on va
			local nb = button(actCard, {
				text = "", autoY = false, size = UDim2.new(1, 0, 0, 50),
				variant = "primary", radius = R.md, order = 2,
				onClick = function() showExercise(nextEx, nextIdx) end,
			})
			pad(nb, 14, 0, 12, 0)
			local nbRow = frame(nb, { transparent = true, autoY = false, size = UDim2.new(1, 0, 1, 0) })
			local nbl2 = hlist(nbRow, 8); nbl2.VerticalAlignment = Enum.VerticalAlignment.Center
			local nbInfo = frame(nbRow, { transparent = true, autoY = false, size = UDim2.new(1, -20, 1, 0), order = 1 })
			local nbv = vlist(nbInfo, 2); nbv.VerticalAlignment = Enum.VerticalAlignment.Center
			label(nbInfo, { text = "EXERCICE SUIVANT", ts = 9, bold = true, color = C.onAccent, order = 1, truncate = true })
			label(nbInfo, { text = nextEx.title, ts = 13, bold = true, color = C.onAccent, truncate = true, order = 2 })
			local nbc = label(nbRow, { text = "›", ts = 20, bold = true, color = C.onAccent, align = Enum.TextXAlignment.Center, size = UDim2.new(0, 16, 1, 0), order = 2 })
			nbc.TextYAlignment = Enum.TextYAlignment.Center
			nbc.TextWrapped = false
		else
			local done = frame(actCard, { bg = C.accentBg, radius = R.md, order = 2 })
			pad(done, 12, 11, 12, 11)
			vlist(done, 4)
			label(done, { text = "Module terminé intégralement !", ts = 12, bold = true, color = C.accent, order = 1 })
			label(done, { text = "Retourne à la liste pour attaquer le module suivant.", ts = 11, color = C.textSec, order = 2 })
			button(done, {
				text = "Revenir aux modules", size = UDim2.new(1, 0, 0, 34), variant = "primary",
				ts = 12, radius = R.sm, order = 3, onClick = function() showModules("exercices") end,
			})
		end

		button(actCard, {
			text = "Revoir mon code", size = UDim2.new(1, 0, 0, 36), variant = "secondary",
			ts = 12, radius = R.sm, order = 3,
			onClick = function()
				local s = getScript()
				if s then plugin:OpenScript(s) else toast("Script introuvable. Réinitialise-le ci-dessous.", "error") end
			end,
		})
	else
		-- Flux : ouvrir le script → écrire → Play (F5) → validation auto
		button(actCard, {
			text = "Ouvrir le script", size = UDim2.new(1, 0, 0, 44),
			variant = "primary", ts = 14, radius = R.md, order = 1,
			onClick = function()
				plugin:OpenScript(ensureScript())
				toast("Écris ton code, puis appuie sur Play (F5) pour valider.", "info")
			end,
		})

		-- Les 3 étapes du flux, numérotées : l'élève sait toujours quoi faire.
		local steps = frame(actCard, { bg = C.bgSurface, radius = R.md, order = 2, stroke = C.borderSoft })
		pad(steps, 13, 12, 13, 12)
		vlist(steps, 9)
		local stepDefs = {
			{ "Écris ton code dans le script", false },
			{ "Appuie sur Play (F5) pour lancer ton jeu", false },
			{ "La validation se fait toute seule", true },
		}
		for i, sd in ipairs(stepDefs) do
			local sRow = frame(steps, { transparent = true, order = i })
			hlist(sRow, 9, Enum.VerticalAlignment.Top)
			local numB = frame(sRow, {
				bg = sd[2] and C.accent or C.card, autoY = false,
				size = UDim2.new(0, 18, 0, 18), radius = R.pill, order = 1,
				stroke = sd[2] and C.accent or C.border, strokeThick = 1,
			})
			local nlb = label(numB, {
				text = tostring(i), ts = 10, bold = true,
				color = sd[2] and C.onAccent or C.textMuted,
				align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
			})
			nlb.TextYAlignment = Enum.TextYAlignment.Center
			nlb.TextWrapped = false
			label(sRow, {
				text = sd[1], ts = 12, medium = sd[2], color = sd[2] and C.accent or C.text,
				size = UDim2.new(1, -27, 0, 0), order = 2,
			})
		end

		button(actCard, {
			text = "Actualiser l'état", size = UDim2.new(1, 0, 0, 32), variant = "ghost",
			tc = C.textSec, ts = 11, radius = R.sm, order = 3, onClick = refreshStatus,
		})
	end

	-- reset code (commun) — action destructive, donc discrète et en dernier
	button(actCard, {
		text = "Réinitialiser le code de départ", size = UDim2.new(1, 0, 0, 30), variant = "ghost",
		tc = C.textMuted, ts = 10, radius = R.sm, order = 8,
		onClick = function()
			local s = ensureScript()
			plugin:OpenScript(s)
			task.spawn(function()
				-- attendre l'ouverture du document avant de l'éditer
				for _ = 1, 30 do
					if ScriptEditorService:FindScriptDocument(s) then break end
					task.wait()
				end
				setScriptSource(s, exercise.starterCode or "-- Écris ton code ici\n")
				toast("Code réinitialisé.", "info")
			end)
		end,
	})

	-- ── indices ──
	-- Révélés un par un : l'élève ne voit pas toute l'aide d'un coup, ce qui
	-- préserve l'effort de recherche.
	if exercise.hints and #exercise.hints > 0 then
		local hintCard = card(scroll, { order = 5, stroke = C.border })
		pad(hintCard, 14, 13, 14, 13)
		vlist(hintCard, 9)

		local hHead = frame(hintCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = 1 })
		hlist(hHead, 7, Enum.VerticalAlignment.Center)
		local hIcon = label(hHead, { text = "?", ts = 11, bold = true, color = C.orange, size = UDim2.new(0, 16, 1, 0), align = Enum.TextXAlignment.Center, order = 1 })
		hIcon.TextYAlignment = Enum.TextYAlignment.Center
		hIcon.TextWrapped = false
		local hTitle = label(hHead, { text = "INDICES", ts = 10, bold = true, color = C.orange, size = UDim2.new(1, -50, 1, 0), order = 2 })
		hTitle.TextYAlignment = Enum.TextYAlignment.Center
		local hCount = label(hHead, { text = "0/" .. #exercise.hints, ts = 10, bold = true, color = C.textMuted, align = Enum.TextXAlignment.Right, size = UDim2.new(0, 34, 1, 0), order = 3 })
		hCount.TextYAlignment = Enum.TextYAlignment.Center

		local revealed = 0
		local hintsBox = frame(hintCard, { transparent = true, order = 2 })
		vlist(hintsBox, 7)

		local revealBtn
		local function revealNext()
			revealed += 1
			local hintRow = frame(hintsBox, { bg = C.orangeBg, radius = R.sm, order = revealed })
			pad(hintRow, 11, 9, 11, 9)
			hlist(hintRow, 8, Enum.VerticalAlignment.Top)
			local n = label(hintRow, { text = tostring(revealed) .. ".", ts = 11, bold = true, color = C.orange, size = UDim2.new(0, 14, 0, 16), order = 1 })
			n.TextWrapped = false
			label(hintRow, { text = exercise.hints[revealed], ts = 12, color = C.text, size = UDim2.new(1, -22, 0, 0), lh = 1.25, order = 2 })
			hintRow.BackgroundTransparency = 1
			TweenService:Create(hintRow, TweenInfo.new(0.2), { BackgroundTransparency = 0 }):Play()

			hCount.Text = revealed .. "/" .. #exercise.hints
			if revealed >= #exercise.hints then
				revealBtn.Visible = false
			else
				revealBtn.Text = "Afficher un autre indice  (" .. (#exercise.hints - revealed) .. " restant" .. ((#exercise.hints - revealed) > 1 and "s" or "") .. ")"
			end
		end

		revealBtn = button(hintCard, {
			text = "Afficher un indice", size = UDim2.new(1, 0, 0, 34),
			bg = C.orangeBg, tc = C.orange, hover = shade(C.orangeBg, currentTheme == "dark" and 0.04 or -0.03),
			ts = 12, radius = R.sm, stroke = C.orange, order = 3, onClick = revealNext,
		})
	end
end

-- ── MODE DEV : Construit le jeu étape par étape ──────────────────────────
-- Orchestre : apply → instances (check) → script → code → toast.
-- Idempotent : exécuter N fois = même résultat que 1 fois.
local function resolveOrCreatePath(path)
	if type(path) ~= "string" or path == "" then return workspace end
	local segments = string.split(path, ".")
	local current
	-- Premier segment = service ou workspace
	local start = segments[1]
	if start == "Workspace" or start == "workspace" then
		current = workspace
	else
		local ok, svc = pcall(function() return game:GetService(start) end)
		current = ok and svc or workspace
	end
	-- Parcourir/créer les segments intermédiaires
	for i = 2, #segments do
		local child = current:FindFirstChild(segments[i])
		if not child then
			child = Instance.new("Folder")
			child.Name = segments[i]
			child.Parent = current
		end
		current = child
	end
	return current
end

local function createScriptAtPath(target)
	if type(target) ~= "string" or target == "" then return nil end
	local segments = string.split(target, ".")
	local scriptName = segments[#segments]
	table.remove(segments)
	local parentPath = table.concat(segments, ".")
	local parent = resolveOrCreatePath(parentPath)
	if not parent then return nil end
	-- Déterminer le type de script
	local cls = "Script"
	local lower = scriptName:lower()
	if lower:find("local") or lower:find("client") then cls = "LocalScript"
	elseif lower:find("module") or lower:find("config") then cls = "ModuleScript" end
	local inst = Instance.new(cls)
	inst.Name = scriptName
	inst.Parent = parent
	return inst
end

local function buildDevStep(stepData)
	-- 1. APPLY : générer la structure (Folders, Parts)
	if stepData.apply then
		pcall(function() applyStepSetup(stepData.apply) end)
	end

	-- 2. INSTANCES : créer depuis check.all (si type = instances)
	if stepData.check and stepData.check.type == "instances" then
		for _, req in ipairs(stepData.check.all or {}) do
			pcall(function()
				local inst = req.path and resolveByPath(req.path)
				if not inst then
					-- Résoudre le parent et créer l'instance
					local parts = string.split(req.path or "", ".")
					local instName = parts[#parts]
					table.remove(parts)
					local parentPath = table.concat(parts, ".")
					local parent = resolveOrCreatePath(parentPath)
					local cls = req.class or "Part"
					inst = Instance.new(cls)
					inst.Name = instName
					inst.Parent = parent
					if inst:IsA("BasePart") then inst.Anchored = true end
				end
				-- Appliquer les propriétés attendues
				if inst and req.props then
					_setInstanceProps(inst, req.props)
				end
			end)
		end
	end

	-- 3. SCRIPT : résoudre ou créer le script cible
	local target = stepData.scriptTarget or (stepData.check and stepData.check.script) or nil
	local scriptInst = target and resolveByPath(target) or nil

	-- 4. CODE : injecter le code accumulé
	if stepData.code and stepData.code ~= "" then
		if not scriptInst and target then
			scriptInst = createScriptAtPath(target)
		end
		if scriptInst and scriptInst:IsA("LuaSourceContainer") then
			setScriptSource(scriptInst, stepData.code)
		end
	end

	-- 5. Toast succès
	toast("Etape construite. Clique Verifier !", "success")
end

-- ── DÉTAIL PROJET (fil rouge / capstone) ──
showProject = function(moduleId, projTitle)
	local root, myGen = newScreen()
	clearExerciseCtx() -- on fait le PROJET : un F5 ici ne doit valider aucun exercice
	pcall(function() plugin:SetSetting("LB_last_tab", "projets") end)
	-- Contexte projet : au playtest (F5), les étapes seront vérifiées automatiquement
	pcall(function() plugin:SetSetting(SETTING_PROJ_CTX, { moduleId = moduleId, moduleTitle = projTitle }) end)

	-- header nav (retour vers l'onglet Projets)
	makeNavBar(root, {
		eyebrow = "PROJET GUIDÉ",
		title = projTitle or "Projet",
		onBack = function() showModules("projets") end,
	})

	local scroll = makeScroll(root, NAV_H)
	loadingState(scroll, myGen, nil, "detail")

	task.spawn(function()
		local proj = api("GET", "/projects/" .. moduleId)
		if state.gen ~= myGen then return end
		if not proj then showError(nil, function() showProject(moduleId, projTitle) end); return end

		local steps = proj.steps or {}
		local checkedSet = {}
		for _, id in ipairs(proj.checkedSteps or {}) do checkedSet[id] = true end
		local completedFlag = proj.completed == true

		local draw -- forward

		-- Vérifie l'étape contre la place Studio, puis la coche si OK.
		-- (Il n'y a pas de cochage manuel : la validation est toujours vérifiée.)
		local function verifyStep(step)
			local ok, msg = runStepCheck(step.check)
			if ok then
				if not checkedSet[step.id] then
					checkedSet[step.id] = true
					draw()
					task.spawn(function()
						api("POST", "/projects/step", { moduleId = moduleId, stepId = step.id, checked = true })
					end)
				end
				toast("Étape validée dans Studio !", "success")
			else
				toast("Pas encore : " .. (msg or "conditions non remplies") .. ". Vérifie l'Explorer.", "error")
			end
		end

		local function completeProject()
			task.spawn(function()
				local r = api("POST", "/projects/complete", { moduleId = moduleId })
				if state.gen ~= myGen then return end
				if r and r.success then
					completedFlag = true
					toast("Projet terminé ! Bravo.", "success")
					draw()
				else
					toast((r and r.error) or "Coche d'abord toutes les étapes.", "error")
				end
			end)
		end

		draw = function()
			for _, ch in ipairs(scroll:GetChildren()) do
				if ch:IsA("GuiObject") then ch:Destroy() end
			end

			local total = #steps
			local doneCount = 0
			for _, st in ipairs(steps) do if checkedSet[st.id] then doneCount += 1 end end
			local allChecked = total > 0 and doneCount >= total

			-- Le projet est du track "build" → violet, comme sur le site.
			local trackColor = C.purple
			local progColor = completedFlag and C.green or trackColor

			-- En-tête : pastille de track + titre + description
			local head = card(scroll, { order = 1, stroke = completedFlag and C.green or C.border })
			pad(head, 15, 14, 15, 14)
			vlist(head, 9)

			local headTop = frame(head, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 34), order = 1 })
			hlist(headTop, 10, Enum.VerticalAlignment.Center)
			iconBadge(headTop, completedFlag and "✓" or "[ ]", progColor, 1, 34)
			local htInfo = frame(headTop, { transparent = true, autoY = false, size = UDim2.new(1, -45, 1, 0), order = 2 })
			local htv = vlist(htInfo, 2); htv.VerticalAlignment = Enum.VerticalAlignment.Center
			label(htInfo, { text = "PROJET GUIDÉ", ts = 9, bold = true, color = progColor, truncate = true, order = 1 })
			label(htInfo, { text = proj.title or "Projet", ts = 15, bold = true, color = C.textBright, truncate = true, order = 2 })

			if proj.description and proj.description ~= "" then
				label(head, { text = proj.description, ts = 12, color = C.textSec, lh = 1.3, order = 2 })
			end

			-- Progression intégrée à l'en-tête : une seule carte au lieu de deux
			local prow = frame(head, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 15), order = 3 })
			hlist(prow, 0, Enum.VerticalAlignment.Center)
			local pTitle = label(prow, {
				text = completedFlag and "Projet terminé" or "Étapes validées",
				ts = 11, medium = true, color = C.textMuted, size = UDim2.new(1, -60, 1, 0), order = 1, truncate = true,
			})
			pTitle.TextYAlignment = Enum.TextYAlignment.Center
			local pCount = label(prow, {
				text = doneCount .. "/" .. total, ts = 11, bold = true, color = progColor,
				align = Enum.TextXAlignment.Right, size = UDim2.new(0, 60, 1, 0), order = 2,
			})
			pCount.TextYAlignment = Enum.TextYAlignment.Center
			progressBar(head, total > 0 and doneCount / total or 0, progColor, 4, 7)

			-- Contexte narratif : le décor du projet fil rouge. Le serveur le
			-- renvoyait déjà sans que le plugin l'affiche — c'est pourtant ce
			-- qui donne du sens à ce qu'on construit.
			if proj.context and proj.context ~= "" then
				local ctxCard = frame(scroll, { bg = C.purpleBg, radius = R.lg, order = 2 })
				pad(ctxCard, 14, 12, 14, 12)
				vlist(ctxCard, 7)
				label(ctxCard, { text = "LE CONTEXTE", ts = 9, bold = true, color = trackColor, order = 1 })
				label(ctxCard, { text = proj.context, ts = 12, color = C.text, lh = 1.35, order = 2 })
			end

			-- Méta (objectif / outils / durée) — grille de libellés discrets
			local metas = {}
			if proj.objective then metas[#metas + 1] = { "OBJECTIF", proj.objective } end
			if proj.tools then metas[#metas + 1] = { "OUTILS", proj.tools } end
			if proj.duration then metas[#metas + 1] = { "DURÉE", proj.duration } end
			if #metas > 0 then
				local metaCard = frame(scroll, { bg = C.bgSurface, radius = R.lg, order = 3, stroke = C.borderSoft })
				pad(metaCard, 14, 12, 14, 12)
				vlist(metaCard, 10)
				for i, mt in ipairs(metas) do
					local block = frame(metaCard, { transparent = true, order = i })
					vlist(block, 3)
					label(block, { text = mt[1], ts = 9, bold = true, color = trackColor, order = 1 })
					label(block, { text = mt[2], ts = 11, color = C.text, lh = 1.25, order = 2 })
				end
			end

			sectionTitle(scroll, "ÉTAPES DE CONSTRUCTION", 4, doneCount .. "/" .. total)

			-- Première étape non cochée : c'est là que l'élève doit travailler,
			-- donc elle est développée et mise en avant. Les étapes déjà
			-- validées se replient sur une seule ligne pour dégager la vue.
			local currentIdx
			for i, st in ipairs(steps) do
				if not checkedSet[st.id] then currentIdx = i; break end
			end

			-- Étapes
			for idx, step in ipairs(steps) do
				local checked = checkedSet[step.id] == true
				local isCurrent = (idx == currentIdx)

				-- Étape validée → ligne compacte repliée
				if checked then
					local doneRow = frame(scroll, { bg = C.card, radius = R.md, order = 10 + idx, autoY = false, size = UDim2.new(1, 0, 0, 42) })
					doneRow.BackgroundTransparency = 0.4
					stroke(doneRow, C.green, 1).Transparency = 0.55
					pad(doneRow, 12, 0, 12, 0)
					hlist(doneRow, 10, Enum.VerticalAlignment.Center)
					local dBadge = frame(doneRow, { bg = C.green, autoY = false, size = UDim2.new(0, 20, 0, 20), radius = R.pill, order = 1 })
					local dl = label(dBadge, { text = "✓", ts = 12, bold = true, color = C.white, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
					dl.TextYAlignment = Enum.TextYAlignment.Center
					dl.TextWrapped = false
					local dt = label(doneRow, {
						text = "Étape " .. idx .. " · " .. (step.title or ""),
						ts = 12, medium = true, color = C.textSec,
						size = UDim2.new(1, -32, 1, 0), truncate = true, order = 2,
					})
					dt.TextYAlignment = Enum.TextYAlignment.Center
					continue
				end

				local stepCard = card(scroll, {
					order = 10 + idx,
					stroke = isCurrent and trackColor or C.border,
				})
				if isCurrent then
					local st = stepCard:FindFirstChildOfClass("UIStroke")
					if st then st.Thickness = 1.5 end
				end
				pad(stepCard, 13, 13, 13, 13)
				vlist(stepCard, 9)

				-- Ligne titre : indicateur d'état (lecture seule) + titre.
				-- La validation est automatique (F5) ou via « Vérifier l'étape ».
				local topR = frame(stepCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 26), order = 1 })
				hlist(topR, 10, Enum.VerticalAlignment.Center)
				local statusBadge = frame(topR, {
					bg = isCurrent and trackColor or C.bgSurface, autoY = false,
					size = UDim2.new(0, 26, 0, 26), radius = R.sm, order = 1,
					stroke = isCurrent and trackColor or C.border, strokeThick = 1,
				})
				local statusLbl = label(statusBadge, {
					text = tostring(idx), ts = 13, bold = true,
					color = isCurrent and C.white or C.textMuted,
					align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0),
				})
				statusLbl.TextYAlignment = Enum.TextYAlignment.Center
				statusLbl.TextWrapped = false

				local titleLbl = label(topR, {
					text = step.title or ("Étape " .. idx), ts = 13, bold = true,
					color = C.textBright, size = UDim2.new(1, -36 - (isCurrent and 56 or 0), 1, 0),
					order = 2, truncate = true,
				})
				titleLbl.TextYAlignment = Enum.TextYAlignment.Center
				if isCurrent then
					pill(topR, "EN COURS", trackColor, C.white, 3)
				end

				-- Intro
				if step.intro and step.intro ~= "" then
					label(stepCard, { text = step.intro, ts = 12, color = C.textSec, lh = 1.3, order = 2 })
				end

				-- À faire (liste numérotée, aérée)
				local hasTodo = (type(step.todo) == "table" and #step.todo > 0) or (step.todoText and step.todoText ~= "")
				if hasTodo then
					local todoBox = frame(stepCard, { bg = C.bgSurface, radius = R.sm, stroke = C.borderSoft, order = 3 })
					pad(todoBox, 12, 11, 12, 11)
					vlist(todoBox, 8)
					label(todoBox, { text = "À FAIRE", ts = 9, bold = true, color = trackColor, order = 1 })
					if type(step.todo) == "table" and #step.todo > 0 then
						for i, item in ipairs(step.todo) do
							local rowT = frame(todoBox, { transparent = true, order = i + 1 })
							hlist(rowT, 9, Enum.VerticalAlignment.Top)
							-- Puce plutôt qu'un numéro : la liste « à faire » n'est
							-- pas forcément séquentielle, contrairement aux étapes.
							local dot = frame(rowT, { bg = trackColor, autoY = false, size = UDim2.new(0, 5, 0, 5), radius = R.pill, order = 1 })
							dot.Position = UDim2.new(0, 0, 0, 6)
							label(rowT, { text = item, ts = 12, color = C.text, size = UDim2.new(1, -14, 0, 0), lh = 1.28, order = 2 })
						end
					else
						label(todoBox, { text = step.todoText, ts = 12, color = C.text, lh = 1.28, order = 2 })
					end
				end

				-- Pourquoi (note explicative discrète)
				if step.why and step.why ~= "" then
					local whyBox = frame(stepCard, { bg = C.bgSurface, radius = R.sm, order = 4 })
					pad(whyBox, 12, 9, 12, 9)
					vlist(whyBox, 4)
					label(whyBox, { text = "POURQUOI", ts = 9, bold = true, color = C.textMuted, order = 1 })
					label(whyBox, { text = step.why, ts = 11, color = C.textSec, lh = 1.28, order = 2 })
				end

				-- Indice
				if step.hint and step.hint ~= "" then
					local hintBox = frame(stepCard, { bg = C.orangeBg, radius = R.sm, order = 5 })
					pad(hintBox, 12, 9, 12, 9)
					vlist(hintBox, 4)
					label(hintBox, { text = "INDICE", ts = 9, bold = true, color = C.orange, order = 1 })
					label(hintBox, { text = step.hint, ts = 11, color = C.text, lh = 1.28, order = 2 })
				end

				-- Code de l'étape — affiché EN RÉFÉRENCE seulement (lecture).
				-- Pas d'insertion auto : l'élève écrit le code lui-même (apprentissage).
				if step.code and step.code ~= "" then
					local codeBox = frame(stepCard, { bg = currentTheme == "dark" and C.bg or C.elevated, radius = R.sm, stroke = C.borderSoft, order = 6 })
					pad(codeBox, 12, 11, 12, 11)
					vlist(codeBox, 7)
					local cHead = frame(codeBox, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 12), order = 1 })
					hlist(cHead, 6, Enum.VerticalAlignment.Center)
					local ch1 = label(cHead, { text = "EXEMPLE", ts = 9, bold = true, color = C.textMuted, size = UDim2.new(0, 60, 1, 0), order = 1 })
					ch1.TextYAlignment = Enum.TextYAlignment.Center
					local ch2 = label(cHead, { text = "à recopier toi-même", ts = 9, color = C.textMuted, size = UDim2.new(1, -66, 1, 0), order = 2, truncate = true })
					ch2.TextYAlignment = Enum.TextYAlignment.Center
					-- Même coloration que les blocs de Bloxi : l'exemple d'étape
					-- est du Luau, l'élève doit y retrouver ses repères.
					local okStepHL, stepPainted = pcall(highlightLuau, step.code)
					local codeLbl = label(codeBox, {
						text = okStepHL and stepPainted or step.code,
						rich = okStepHL or nil,
						ts = 11, color = C.text, lh = 1.35, order = 2,
					})
					codeLbl.Font = Enum.Font.Code
				end

				-- Génération automatique de la structure (setup mécanique) — 1 clic.
				-- Génère les Parts/dossiers, puis vérifie et coche l'étape.
				if step.apply then
					button(stepCard, {
						text = "Générer la structure automatiquement", size = UDim2.new(1, 0, 0, 36),
						bg = C.purpleBg, tc = trackColor, ts = 12, radius = R.sm, stroke = trackColor,
						hover = shade(C.purpleBg, currentTheme == "dark" and 0.04 or -0.03), order = 8,
						onClick = function()
							local ok, msg = applyStepSetup(step.apply)
							if ok then
								toast("Structure générée dans Studio.", "success")
								task.wait(0.15)
								verifyStep(step) -- vérifie + coche automatiquement
							else
								toast(msg or "Impossible de générer la structure.", "error")
							end
						end,
					})
				end

				-- Bouton de vérification auto (si l'étape a une règle)
				if step.check then
					button(stepCard, {
						text = "Vérifier l'étape dans Studio", size = UDim2.new(1, 0, 0, 38),
						variant = "primary", ts = 13, radius = R.sm, order = 9,
						onClick = function() verifyStep(step) end,
					})
				end

				-- ── DEV MODE : construit le jeu ET permet la vérification ──
				-- Réservé au compte devwithdono. Crée la structure, les instances,
				-- injecte le code accumulé, puis laisse vérifier manuellement.
				if state.userId == "87239d15-f544-4cc2-86a1-9ae35980a3e5" then
					button(stepCard, {
						text = "DEV · Construire l'étape", size = UDim2.new(1, 0, 0, 32),
						bg = Color3.fromRGB(40, 40, 40), tc = Color3.fromRGB(255, 200, 0), ts = 11,
						radius = R.sm, hover = Color3.fromRGB(55, 55, 55), order = 10,
						onClick = function()
							buildDevStep(step)
						end,
					})
				end
			end

			-- Validation finale
			if completedFlag then
				local doneCard = frame(scroll, { bg = C.greenBg, radius = R.lg, stroke = C.green, order = 9000 })
				pad(doneCard, 14, 13, 14, 13)
				hlist(doneCard, 11, Enum.VerticalAlignment.Center)
				local dBadge = frame(doneCard, { bg = C.green, autoY = false, size = UDim2.new(0, 30, 0, 30), radius = R.pill, order = 1 })
				local dl = label(dBadge, { text = "✓", ts = 16, bold = true, color = C.white, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
				dl.TextYAlignment = Enum.TextYAlignment.Center
				dl.TextWrapped = false
				local dInfo = frame(doneCard, { transparent = true, size = UDim2.new(1, -41, 0, 0), order = 2 })
				vlist(dInfo, 3)
				label(dInfo, { text = "Projet validé, bravo !", ts = 13, bold = true, color = C.green, order = 1 })
				label(dInfo, { text = "Tu peux passer au projet suivant.", ts = 11, color = C.textSec, order = 2 })
			else
				button(scroll, {
					text = allChecked and "Marquer le projet comme terminé" or ("Encore " .. (total - doneCount) .. " étape" .. ((total - doneCount) > 1 and "s" or "") .. " à valider"),
					size = UDim2.new(1, 0, 0, 44),
					bg = allChecked and C.green or C.card,
					tc = allChecked and C.white or C.textMuted,
					ts = 13, radius = R.md, stroke = allChecked and nil or C.border,
					hover = allChecked and shade(C.green, -0.06) or C.cardHover,
					order = 9000,
					onClick = function()
						if allChecked then
							completeProject()
						else
							toast("Valide d'abord toutes les étapes (bouton « Vérifier » ou Play F5).", "info")
						end
					end,
				})
			end
		end

		draw()
	end)
end

-- ═══════════════════════════════ INIT ═════════════════════════════════
-- Après un playtest, l'UI Edit se recharge : on réouvre l'exercice testé.
local function tryRestoreAfterPlaytest()
	local session = select(2, pcall(function() return plugin:GetSetting(SETTING_SESSION) end))
	local seen = select(2, pcall(function() return plugin:GetSetting(SETTING_SEEN) end))
	local ctx = select(2, pcall(function() return plugin:GetSetting(SETTING_CTX) end))
	if not session or session == seen then return false end
	if type(ctx) ~= "table" or not ctx.moduleId or not ctx.exId then return false end
	-- NB : on ne consomme PAS SETTING_SEEN ici ; l'écran d'exercice s'en charge
	-- via handleResult() pour refléter le résultat de la partie.
	task.spawn(function()
		local exs = api("GET", "/exercises?moduleId=" .. ctx.moduleId)
		state.currentModule = { id = ctx.moduleId, title = ctx.moduleTitle or "Module" }
		state.exercises = exs or {}
		local target, idx
		for i, ex in ipairs(state.exercises) do
			if ex.id == ctx.exId then target, idx = ex, i; break end
		end
		if target then showExercise(target, idx) else showModules() end
	end)
	return true
end

-- Après un playtest de PROJET, l'UI Edit se recharge : on réouvre le projet
-- (avec la progression mise à jour par la validation en jeu).
local function tryRestoreAfterProjectPlaytest()
	local session = select(2, pcall(function() return plugin:GetSetting(SETTING_SESSION) end))
	local seen = select(2, pcall(function() return plugin:GetSetting(SETTING_SEEN) end))
	local ctx = select(2, pcall(function() return plugin:GetSetting(SETTING_PROJ_CTX) end))
	if not session or session == seen then return false end
	if type(ctx) ~= "table" or not ctx.moduleId then return false end
	pcall(function() plugin:SetSetting(SETTING_SEEN, session) end)
	local res = select(2, pcall(function() return plugin:GetSetting(SETTING_PROJ_RESULT) end))
	task.spawn(function()
		showProject(ctx.moduleId, ctx.moduleTitle or "Projet")
		if type(res) == "table" and res.total then
			if res.newly and res.newly > 0 then
				toast("+" .. res.newly .. " étape(s) validée(s) depuis le jeu ! (" .. (res.done or 0) .. "/" .. res.total .. ")", "success")
			else
				toast("Progression : " .. (res.done or 0) .. "/" .. res.total .. " étapes.", "info")
			end
		end
	end)
	return true
end


-- ═══════════════════════════ PLAYTEST VALIDATION ══════════════════════
-- Exécuté UNIQUEMENT dans le DataModel serveur d'un playtest : capte la
-- sortie réelle du jeu, valide (code + sortie) auprès du serveur, puis
-- imprime le résultat DIRECTEMENT dans la console Roblox.

-- Bannière visuelle affichée DANS LE JEU (PlayerGui) pendant un playtest.
-- Partagée par la validation d'exercice et de projet.
local function _showGameBanner(success, title, subtitle)
	local player = Players:GetPlayers()[1]
	local t = 0
	while not player and t < 5 do task.wait(0.2); t += 0.2; player = Players:GetPlayers()[1] end
	if not player then return end
	local pg = player:FindFirstChildOfClass("PlayerGui")
	if not pg then return end
	pcall(function()
		-- Palette de marque en dur : ce code tourne dans le DataModel du
		-- playtest, où le thème de l'UI Edit n'est pas pertinent. On garde le
		-- fond navy du site pour que la bannière reste reconnaissable en jeu.
		local accent = success and Color3.fromRGB(16, 185, 129) or Color3.fromRGB(239, 68, 68)
		local surface = Color3.fromRGB(30, 41, 59)   -- --bg-card (dark)
		local headings = Color3.fromRGB(248, 250, 252)
		local secondary = Color3.fromRGB(148, 163, 184)

		local gui = Instance.new("ScreenGui")
		gui.Name = "LearnBloxResult"; gui.ResetOnSpawn = false; gui.IgnoreGuiInset = true
		gui.DisplayOrder = 999; gui.Parent = pg
		Debris:AddItem(gui, 6)

		local card = Instance.new("Frame")
		card.AnchorPoint = Vector2.new(0.5, 0); card.Position = UDim2.new(0.5, 0, 0, -140)
		card.Size = UDim2.new(0, 460, 0, 96); card.BackgroundColor3 = surface
		card.BorderSizePixel = 0; card.Parent = gui
		local cc = Instance.new("UICorner"); cc.CornerRadius = UDim.new(0, 16); cc.Parent = card
		local cs = Instance.new("UIStroke"); cs.Color = accent; cs.Thickness = 2; cs.Parent = card
		local cp = Instance.new("UIPadding")
		cp.PaddingLeft = UDim.new(0, 16); cp.PaddingRight = UDim.new(0, 18)
		cp.PaddingTop = UDim.new(0, 14); cp.PaddingBottom = UDim.new(0, 14); cp.Parent = card

		local badge = Instance.new("Frame")
		badge.Size = UDim2.new(0, 54, 0, 54); badge.Position = UDim2.new(0, 0, 0.5, 0)
		badge.AnchorPoint = Vector2.new(0, 0.5); badge.BackgroundColor3 = accent
		badge.BorderSizePixel = 0; badge.Parent = card
		local bc = Instance.new("UICorner"); bc.CornerRadius = UDim.new(0, 14); bc.Parent = badge
		local bg = Instance.new("UIGradient")
		bg.Color = ColorSequence.new(accent, accent:Lerp(Color3.new(0, 0, 0), 0.18))
		bg.Rotation = 45; bg.Parent = badge
		local icon = Instance.new("TextLabel"); icon.BackgroundTransparency = 1; icon.Size = UDim2.new(1, 0, 1, 0)
		icon.Font = Enum.Font.GothamBold; icon.TextSize = 28; icon.TextColor3 = Color3.fromRGB(255, 255, 255)
		icon.Text = success and "✓" or "X"; icon.AutoLocalize = false; icon.Parent = badge

		local titleLbl = Instance.new("TextLabel"); titleLbl.BackgroundTransparency = 1
		titleLbl.Position = UDim2.new(0, 68, 0, 4); titleLbl.Size = UDim2.new(1, -68, 0, 26)
		titleLbl.Font = Enum.Font.GothamBold; titleLbl.TextSize = 19; titleLbl.TextXAlignment = Enum.TextXAlignment.Left
		titleLbl.TextColor3 = headings; titleLbl.Text = title; titleLbl.AutoLocalize = false; titleLbl.Parent = card

		local subLbl = Instance.new("TextLabel"); subLbl.BackgroundTransparency = 1
		subLbl.Position = UDim2.new(0, 68, 0, 32); subLbl.Size = UDim2.new(1, -68, 0, 36)
		subLbl.Font = Enum.Font.Gotham; subLbl.TextSize = 13; subLbl.TextWrapped = true
		subLbl.TextXAlignment = Enum.TextXAlignment.Left; subLbl.TextYAlignment = Enum.TextYAlignment.Top
		subLbl.TextColor3 = secondary; subLbl.Text = subtitle or ""; subLbl.AutoLocalize = false; subLbl.Parent = card

		-- Jauge de fermeture : indique combien de temps la bannière reste
		local timerTrack = Instance.new("Frame")
		timerTrack.AnchorPoint = Vector2.new(0, 1)
		timerTrack.Position = UDim2.new(0, 0, 1, 6)
		timerTrack.Size = UDim2.new(1, 0, 0, 3)
		timerTrack.BackgroundColor3 = accent
		timerTrack.BackgroundTransparency = 0.75
		timerTrack.BorderSizePixel = 0
		timerTrack.Parent = card
		local ttc = Instance.new("UICorner"); ttc.CornerRadius = UDim.new(1, 0); ttc.Parent = timerTrack
		local timerFill = Instance.new("Frame")
		timerFill.Size = UDim2.new(1, 0, 1, 0); timerFill.BackgroundColor3 = accent
		timerFill.BorderSizePixel = 0; timerFill.Parent = timerTrack
		local tfc = Instance.new("UICorner"); tfc.CornerRadius = UDim.new(1, 0); tfc.Parent = timerFill

		TweenService:Create(card, TweenInfo.new(0.42, Enum.EasingStyle.Back, Enum.EasingDirection.Out),
			{ Position = UDim2.new(0.5, 0, 0, 24) }):Play()
		TweenService:Create(timerFill, TweenInfo.new(4.6, Enum.EasingStyle.Linear),
			{ Size = UDim2.new(0, 0, 1, 0) }):Play()
		task.delay(4.6, function()
			if card and card.Parent then
				TweenService:Create(card, TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.In),
					{ Position = UDim2.new(0.5, 0, 0, -140) }):Play()
			end
		end)
	end)
end


local function startPlaytestValidation()
	local ctx = select(2, pcall(function() return plugin:GetSetting(SETTING_CTX) end))
	if type(ctx) ~= "table" or not ctx.exId or not ctx.moduleId then
		return -- aucun exercice LearnBlox en cours : on ne fait rien
	end
	local exId, moduleId = ctx.exId, ctx.moduleId
	local scriptName = "LearnBlox_" .. moduleId .. "_" .. exId

	-- Capture de la sortie du jeu. On garde deux flux : `buffer` (tout, pour
	-- la validation côté serveur) et `errors` (uniquement les erreurs Lua,
	-- pour le diagnostic affiché à l'élève). MessageOut fournit le type du
	-- message en second argument — c'est lui qui distingue print et erreur.
	local buffer = {}
	local errors = {}
	local function recordError(text)
		-- On ignore nos propres messages : ils ne viennent pas du code de l'élève
		if text:find("[LearnBlox]", 1, true) then return end
		errors[#errors + 1] = text
		if #errors > 12 then table.remove(errors, 1) end
		-- Partagé avec le DataModel d'édition : Bloxi lit ces erreurs pour
		-- répondre à « pourquoi ça plante ? » sans copier-coller.
		pcall(function() plugin:SetSetting(SETTING_ERRORS, errors) end)
	end

	pcall(function()
		for _, item in ipairs(LogService:GetLogHistory()) do
			local msg = tostring(item.message)
			buffer[#buffer + 1] = msg
			if item.messageType == Enum.MessageType.MessageError then recordError(msg) end
		end
	end)
	LogService.MessageOut:Connect(function(message, messageType)
		local msg = tostring(message)
		buffer[#buffer + 1] = msg
		if #buffer > 250 then table.remove(buffer, 1) end
		if messageType == Enum.MessageType.MessageError then recordError(msg) end
	end)

	-- Bannière de résultat affichée dans le jeu : on réutilise le rendu
	-- partagé (_showGameBanner) pour que les exercices et les projets aient
	-- exactement la même carte.
	local showResultGui = _showGameBanner

	local validated = false
	local function runValidation()
		if validated then return end
		validated = true

		-- code réellement exécuté
		local code = ""
		local folder = ServerScriptService:FindFirstChild("LearnBlox")
		local s = folder and folder:FindFirstChild(scriptName)
		if s then code = s.Source end

		-- sortie (80 dernières lignes, tronquées à 300 caractères)
		local out = {}
		for _, t in ipairs(buffer) do
			local txt = t
			if #txt > 300 then txt = txt:sub(1, 300) end
			out[#out + 1] = txt
		end
		while #out > 80 do table.remove(out, 1) end

		local result = api("POST", "/exercises/validate", { exerciseId = exId, moduleId = moduleId, code = code, output = out })

		-- Diagnostic : si le code a planté, on traduit la PREMIÈRE erreur.
		-- C'est presque toujours la cause ; les suivantes en découlent.
		local diag
		if #errors > 0 then
			local what, fix, lineNo = explainError(errors[1])
			if what then
				diag = { what = what, fix = fix, line = lineNo, raw = errors[1] }
			end
		end

		-- persiste le résultat pour que l'UI Edit se mette à jour
		local session = HttpService:GenerateGUID(false)
		pcall(function()
			plugin:SetSetting(SETTING_RESULT, {
				exId = exId,
				moduleId = moduleId,
				success = (result and result.success) or false,
				alreadyCompleted = (result and result.alreadyCompleted) or false,
				points = (result and result.points) or 0,
				error = result and result.error or nil,
				-- Diagnostic affiché dans la carte d'échec de l'UI Edit
				diagWhat = diag and diag.what or nil,
				diagFix = diag and diag.fix or nil,
				diagLine = diag and diag.line or nil,
				-- Erreurs brutes conservées telles quelles. Le playtest tourne
				-- dans un autre DataModel : les settings sont le seul pont.
				rawErrors = (#errors > 0) and { table.unpack(errors, 1, math.min(#errors, 6)) } or nil,
			})
			plugin:SetSetting(SETTING_SESSION, session)
		end)

		-- imprime le résultat DANS LA VRAIE CONSOLE ROBLOX + bannière visuelle
		print("========== LearnBlox ==========")
		if result and result.success then
			-- `points` fait foi : 0 = déjà crédité (site ou Studio).
			local pts = tonumber(result.points) or 0
			if result.alreadyCompleted or pts <= 0 then
				print("[LearnBlox] Exercice déjà validé.")
				showResultGui(true, "Exercice déjà validé", "Tu avais déjà réussi celui-ci.")
			else
				print("[LearnBlox] Exercice VALIDE ! +" .. pts .. " pts")
				print("[LearnBlox] Ce script sera désactivé pour ne pas gêner les prochains exercices.")
				showResultGui(true, "Exercice validé !", "+" .. pts .. " pts · script désactivé pour la suite")
			end
		elseif diag then
			-- Le code a planté : le diagnostic prime sur le message de
			-- validation générique, il dit précisément ce qui ne va pas.
			local place = diag.line and (" (ligne " .. diag.line .. ")") or ""
			warn("[LearnBlox] Ton code a planté" .. place .. " : " .. diag.what)
			print("[LearnBlox] Piste : " .. diag.fix)
			showResultGui(false, "Ton code a planté" .. place, diag.what)
		elseif result and result.error then
			warn("[LearnBlox] Pas encore : " .. result.error)
			showResultGui(false, "Pas encore réussi", result.error)
		else
			warn("[LearnBlox] Impossible de contacter le serveur de validation.")
			showResultGui(false, "Connexion impossible", "Le serveur LearnBlox est injoignable.")
		end
		print("===============================")
	end

	-- laisse le script s'exécuter, puis valide ; filet de sécurité à l'arrêt
	task.spawn(function()
		task.wait(2.5)
		runValidation()
	end)
	pcall(function() game:BindToClose(runValidation) end)
end

-- ═══════════════════════ PLAYTEST VALIDATION — PROJET ═════════════════
-- Au lancer du jeu (F5) pendant un PROJET : vérifie automatiquement chaque
-- étape (runStepCheck) contre la place en cours, coche celles réussies côté
-- serveur, print dans la console + bannière GUI en jeu (comme les exercices).
local function startProjectPlaytestValidation()
	local ctx = select(2, pcall(function() return plugin:GetSetting(SETTING_PROJ_CTX) end))
	if type(ctx) ~= "table" or not ctx.moduleId then return end
	local moduleId = ctx.moduleId

	-- Capture la sortie du jeu (pour les checks type "output")
	local outputBuffer = {}
	pcall(function()
		for _, item in ipairs(LogService:GetLogHistory()) do
			outputBuffer[#outputBuffer + 1] = tostring(item.message)
		end
	end)
	LogService.MessageOut:Connect(function(message)
		outputBuffer[#outputBuffer + 1] = tostring(message)
		if #outputBuffer > 300 then table.remove(outputBuffer, 1) end
	end)

	task.spawn(function()
		task.wait(3) -- laisse la place et les scripts se charger complètement

		local proj = api("GET", "/projects/" .. moduleId)
		if type(proj) ~= "table" or type(proj.steps) ~= "table" then
			warn("[LearnBlox] Projet introuvable pour la validation.")
			return
		end

		local checkedSet = {}
		for _, id in ipairs(proj.checkedSteps or {}) do checkedSet[id] = true end

		local total = #proj.steps
		local newly, alreadyDone = 0, 0

		print("========== LearnBlox — Projet ==========")
		for _, step in ipairs(proj.steps) do
			if checkedSet[step.id] then
				alreadyDone += 1
			elseif step.check then
				local ok, msg = runStepCheck(step.check, outputBuffer)
				if ok then
					newly += 1
					checkedSet[step.id] = true
					api("POST", "/projects/step", { moduleId = moduleId, stepId = step.id, checked = true })
					print("[LearnBlox] ✓ Étape validée : " .. (step.title or step.id))
				else
					print("[LearnBlox] ✗ " .. (step.title or step.id) .. " — " .. (msg or "conditions non remplies"))
				end
			end
		end

		local done = alreadyDone + newly
		local allDone = total > 0 and done >= total
		if allDone then
			api("POST", "/projects/complete", { moduleId = moduleId })
			print("[LearnBlox] PROJET COMPLET ! " .. done .. "/" .. total .. " étapes.")
		else
			print("[LearnBlox] Progression projet : " .. done .. "/" .. total .. " étapes.")
		end
		print("========================================")

		local title, sub
		if allDone then
			title = "Projet terminé !"; sub = done .. "/" .. total .. " étapes validées"
		elseif newly > 0 then
			title = "+" .. newly .. " étape(s) validée(s)"; sub = done .. "/" .. total .. " étapes au total"
		else
			title = "Aucune nouvelle étape"; sub = "Continue la construction dans Studio, puis relance."
		end
		_showGameBanner(allDone or newly > 0, title, sub)

		-- signale à l'UI Edit de rouvrir le projet avec la progression à jour
		pcall(function()
			plugin:SetSetting(SETTING_PROJ_RESULT, { moduleId = moduleId, done = done, total = total, newly = newly })
			plugin:SetSetting(SETTING_SESSION, HttpService:GenerateGUID(false))
		end)
	end)
end

showPairingScreen = function()
	local root, myGen = newScreen()
	local scroll = Instance.new("ScrollingFrame")
	scroll.Size = UDim2.new(1, 0, 1, 0)
	scroll.CanvasSize = UDim2.new(0, 0, 0, 0)
	scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y
	scroll.BackgroundColor3 = C.bg
	scroll.BorderSizePixel = 0
	scroll.ScrollBarThickness = 4
	scroll.ScrollBarImageColor3 = C.border
	scroll.Parent = root
	pad(scroll, 22, 28, 22, 24)
	vlist(scroll, 14)

	-- ── Hero : logo de marque + wordmark + accroche ──
	local hero = frame(scroll, { transparent = true, order = 1 })
	vlist(hero, 11)
	local heroList = hero:FindFirstChildOfClass("UIListLayout")
	if heroList then heroList.HorizontalAlignment = Enum.HorizontalAlignment.Center end

	local logoCircle = frame(hero, { bg = C.accent, autoY = false, size = UDim2.new(0, 54, 0, 54), radius = R.xl, order = 1 })
	gradient(logoCircle, C.accent, C.accentDark, 45)
	local logoIcon = label(logoCircle, { text = "L", ts = 30, bold = true, color = C.onAccent, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
	logoIcon.TextYAlignment = Enum.TextYAlignment.Center
	logoIcon.TextWrapped = false

	local titleLbl = label(hero, { text = "", ts = 20, bold = true, color = C.textBright, order = 2, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 0, 26), rich = true })
	titleLbl.Text = string.format('Learn<font color="rgb(%d,%d,%d)">Blox</font>',
		math.floor(C.accent.R * 255 + 0.5), math.floor(C.accent.G * 255 + 0.5), math.floor(C.accent.B * 255 + 0.5))
	titleLbl.TextYAlignment = Enum.TextYAlignment.Center
	titleLbl.TextWrapped = false

	label(hero, {
		text = "Relie Studio à ton compte pour retrouver tes exercices et ta progression.",
		ts = 12, color = C.textSec, order = 3, align = Enum.TextXAlignment.Center, lh = 1.3,
	})

	-- Spacer
	local spacer = Instance.new("Frame")
	spacer.BackgroundTransparency = 1; spacer.Size = UDim2.new(1, 0, 0, 4); spacer.LayoutOrder = 2; spacer.Parent = scroll

	-- TextBox pour le code (grand, centré, monospace, chiffres uniquement)
	local inputCard = card(scroll, { order = 3 })
	pad(inputCard, 16, 15, 16, 15)
	vlist(inputCard, 11)
	local ilbl = label(inputCard, { text = "CODE D'APPAIRAGE", ts = 10, bold = true, color = C.textMuted, order = 1, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 0, 13) })
	local inputBox = Instance.new("TextBox")
	inputBox.BackgroundColor3 = currentTheme == "dark" and C.bg or C.bgSurface
	inputBox.BorderSizePixel = 0
	inputBox.Size = UDim2.new(1, 0, 0, 56)
	inputBox.Font = Enum.Font.RobotoMono
	inputBox.TextSize = 30
	inputBox.TextColor3 = C.textBright
	inputBox.TextXAlignment = Enum.TextXAlignment.Center
	inputBox.PlaceholderText = "000000"
	inputBox.PlaceholderColor3 = C.textMuted
	inputBox.Text = ""
	inputBox.ClearTextOnFocus = false
	inputBox.LayoutOrder = 2
	inputBox.AutoLocalize = false
	inputBox.Parent = inputCard
	corner(inputBox, R.md)
	stroke(inputBox, C.border, 1)
	local inputPad = Instance.new("UIPadding")
	inputPad.PaddingLeft = UDim.new(0, 12); inputPad.PaddingRight = UDim.new(0, 12)
	inputPad.Parent = inputBox

	-- Filtre de saisie : chiffres uniquement, 6 maximum. Évite d'atteindre le
	-- serveur avec un code manifestement invalide.
	inputBox:GetPropertyChangedSignal("Text"):Connect(function()
		local cleaned = inputBox.Text:gsub("%D", ""):sub(1, 6)
		if cleaned ~= inputBox.Text then inputBox.Text = cleaned end
	end)
	-- Halo au focus
	inputBox.Focused:Connect(function()
		local s = inputBox:FindFirstChildOfClass("UIStroke"); if s then s.Color = C.accent; s.Thickness = 2 end
	end)
	inputBox.FocusLost:Connect(function()
		local s = inputBox:FindFirstChildOfClass("UIStroke"); if s then s.Color = C.border; s.Thickness = 1 end
	end)

	-- Message d'erreur (caché tant qu'il n'y a rien à signaler)
	local errBox = frame(scroll, { bg = C.redBg, radius = R.sm, order = 4, stroke = C.red })
	pad(errBox, 11, 8, 11, 8)
	local errLabel = label(errBox, { text = "", ts = 11, medium = true, color = C.red, align = Enum.TextXAlignment.Center })
	errBox.Visible = false

	-- Bouton Valider (avec état de chargement réel)
	local pairBtn
	local busy = false
	local function submitCode()
		if busy then return end
		local code = inputBox.Text:gsub("%s+", "")
		if #code ~= 6 or not tonumber(code) then
			errLabel.Text = "Entre le code à 6 chiffres affiché sur le site."
			errBox.Visible = true
			return
		end
		errBox.Visible = false
		busy = true
		pairBtn.Text = "Vérification…"
		pairBtn.AutoButtonColor = false
		pairBtn.BackgroundTransparency = 0.35

		task.spawn(function()
			local result, status = apiRaw("POST", "/pair", { code = code })
			if state.gen ~= myGen then return end
			if result and result.paired then
				state.userId = result.userId
				state.username = result.username
				state.modulesCache = nil
				pcall(function() plugin:SetSetting(SETTING_PAIRED_USER, result.userId) end)
				pcall(function() plugin:SetSetting(SETTING_PAIRED_NAME, result.username) end)
				toast("Connecté en tant que " .. (result.username or "?"), "success")
				showModules()
			else
				-- Rendre la main : l'utilisateur doit pouvoir retenter aussitôt
				busy = false
				pairBtn.Text = "Connecter"
				pairBtn.BackgroundTransparency = 0
				errLabel.Text = (result and result.error) or
					(status == 0 and "Serveur injoignable. Vérifie ta connexion et que les requêtes HTTP sont autorisées dans Studio."
					or ("Code refusé (erreur " .. tostring(status or 0) .. ")."))
				errBox.Visible = true
			end
		end)
	end

	pairBtn = button(scroll, {
		text = "Connecter", variant = "primary", ts = 14,
		radius = R.md, order = 5, size = UDim2.new(1, 0, 0, 46),
		onClick = submitCode,
	})

	-- Entrée valide directement : on tape le code, on appuie sur Entrée.
	inputBox.FocusLost:Connect(function(enterPressed)
		if enterPressed then submitCode() end
	end)

	-- Rappel pour les élèves sans compte : le code d'appairage suppose un
	-- compte LearnBlox déjà créé, ce qui n'est pas évident pour quelqu'un
	-- qui découvre juste le plugin.
	local noAccountCard = frame(scroll, { bg = C.bgSurface, radius = R.lg, stroke = C.borderSoft, order = 6 })
	pad(noAccountCard, 14, 12, 14, 12)
	vlist(noAccountCard, 6)
	label(noAccountCard, {
		text = "Pas encore de compte ?", ts = 12, bold = true, color = C.textBright, order = 1,
	})
	label(noAccountCard, {
		text = "Crée un compte gratuit sur learnblox.fr pour débloquer le premier module et obtenir ton code de connexion.",
		ts = 11, color = C.textSec, lh = 1.35, order = 2,
	})
	local noAccountUrl = frame(noAccountCard, { bg = C.card, autoY = false, size = UDim2.new(1, 0, 0, 26), radius = R.xs, order = 3 })
	stroke(noAccountUrl, C.border, 1)
	pad(noAccountUrl, 8, 0, 8, 0)
	local noAccountUrlLbl = label(noAccountUrl, {
		text = "learnblox.fr/rejoindre", ts = 11, bold = true,
		color = C.accent, size = UDim2.new(1, 0, 1, 0), truncate = true,
	})
	noAccountUrlLbl.TextYAlignment = Enum.TextYAlignment.Center
	noAccountUrlLbl.Font = Enum.Font.Code

	-- Instructions
	local spacer2 = Instance.new("Frame")
	spacer2.BackgroundTransparency = 1; spacer2.Size = UDim2.new(1, 0, 0, 8); spacer2.LayoutOrder = 8; spacer2.Parent = scroll

	local helpCard = frame(scroll, { bg = C.bgSurface, radius = R.lg, stroke = C.borderSoft, order = 9 })
	pad(helpCard, 14, 13, 14, 13)
	vlist(helpCard, 11)

	-- En-tête de la carte
	local helpHead = frame(helpCard, { transparent = true, autoY = false, size = UDim2.new(1, 0, 0, 18), order = 1 })
	hlist(helpHead, 7, Enum.VerticalAlignment.Center)
	local hhIcon = label(helpHead, { text = "?", ts = 12, bold = true, color = C.accent, size = UDim2.new(0, 14, 1, 0), align = Enum.TextXAlignment.Center, order = 1 })
	hhIcon.TextYAlignment = Enum.TextYAlignment.Center
	hhIcon.TextWrapped = false
	local hhTitle = label(helpHead, { text = "Comment obtenir le code ?", ts = 12, bold = true, color = C.textBright, size = UDim2.new(1, -22, 1, 0), order = 2 })
	hhTitle.TextYAlignment = Enum.TextYAlignment.Center

	-- Étapes numérotées avec pastille
	local steps = {
		"Va sur learnblox.fr et connecte-toi",
		"Ouvre un exercice ou projet « sur Roblox Studio »",
		"Copie le code affiché ici et clique « Connecter »",
	}
	for i, txt in ipairs(steps) do
		local row = frame(helpCard, { transparent = true, order = i + 1 })
		hlist(row, 10, Enum.VerticalAlignment.Top)
		local num = frame(row, { bg = C.accentBg, autoY = false, size = UDim2.new(0, 20, 0, 20), radius = R.pill, order = 1 })
		local numLbl = label(num, { text = tostring(i), ts = 11, bold = true, color = C.accent, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 1, 0) })
		numLbl.TextYAlignment = Enum.TextYAlignment.Center
		numLbl.TextWrapped = false
		label(row, { text = txt, ts = 12, color = C.textSec, size = UDim2.new(1, -30, 0, 0), lh = 1.28, order = 2 })
	end

	-- Version du plugin (discret, en bas) — utile pour vérifier les mises à jour
	label(scroll, { text = "LearnBlox Studio v" .. VERSION, ts = 10, color = C.textMuted, align = Enum.TextXAlignment.Center, size = UDim2.new(1, 0, 0, 14), order = 20 })
end

local function init()
	-- Vérifie en arrière-plan si une nouvelle version du plugin est dispo
	task.spawn(checkForUpdate)

	-- Essayer de restaurer un appairage sauvegardé
	local savedUserId = select(2, pcall(function() return plugin:GetSetting(SETTING_PAIRED_USER) end))
	local savedUsername = select(2, pcall(function() return plugin:GetSetting(SETTING_PAIRED_NAME) end))

	-- Pas de userId sauvegardé → direct au pairing
	if not savedUserId or type(savedUserId) ~= "string" or #savedUserId == 0 then
		showPairingScreen()
		return
	end

	-- On a un userId → le charger et vérifier avec /me
	state.userId = savedUserId
	state.username = savedUsername or nil

	local root, myGen = newScreen()
	loadingSplash(root, myGen, "Connexion à LearnBlox")
	task.spawn(function()
		local me = api("GET", "/me")
		if state.gen ~= myGen then return end
		if me and me.username then
			state.username = me.username
			state.userId = me.userId
			-- Stats de profil affichées dans l'en-tête (facultatives côté serveur)
			state.streak = tonumber(me.streak) or 0
			state.bestStreak = tonumber(me.bestStreak) or 0
			state.premium = me.premium == true
			state.dev = me.dev == true
			state.moduleAccess = me.moduleAccess == "free" and "free" or "progressive"
			pcall(function() plugin:SetSetting(SETTING_PAIRED_USER, me.userId) end)
			pcall(function() plugin:SetSetting(SETTING_PAIRED_NAME, me.username) end)
			if not tryRestoreAfterPlaytest() and not tryRestoreAfterProjectPlaytest() then
				showModules()
			end
			-- Deep-link : poll /navigate pour ouvrir module/exercice depuis la webapp
			task.spawn(function()
				while widget and widget.Parent do
					task.wait(3)
					local nav = api("GET", "/navigate")
					if type(nav) == "table" and nav.navigate then
						if nav.target == "project" then
							local mods = api("GET", "/modules")
							local modTitle = nav.moduleId
							if type(mods) == "table" then
								for _, m in ipairs(mods) do
									if m.id == nav.moduleId then modTitle = m.title; break end
								end
							end
							widget.Enabled = true
							showProject(nav.moduleId, modTitle)
						elseif nav.exerciseId then
							local exs = api("GET", "/exercises?moduleId=" .. tostring(nav.moduleId))
							if type(exs) == "table" then
								state.currentModule = state.currentModule or {}
								if state.currentModule.id ~= nav.moduleId then
									local mods = api("GET", "/modules")
									local modTitle = nav.moduleId
									if type(mods) == "table" then
										for _, m in ipairs(mods) do
											if m.id == nav.moduleId then modTitle = m.title; break end
										end
									end
									state.currentModule = { id = nav.moduleId, title = modTitle }
									state.exercises = exs
								end
								local target, idx
								for i, ex in ipairs(exs) do
									if ex.id == nav.exerciseId then target, idx = ex, i; break end
								end
								if target then
									widget.Enabled = true
									showExercise(target, idx)
								else
									widget.Enabled = true
									showExercises(nav.moduleId, state.currentModule.title)
								end
							end
						else
							local mods = api("GET", "/modules")
							local modTitle = nav.moduleId
							if type(mods) == "table" then
								for _, m in ipairs(mods) do
									if m.id == nav.moduleId then modTitle = m.title; break end
								end
							end
							widget.Enabled = true
							showExercises(nav.moduleId, modTitle)
						end
					end
				end
			end)
		else
			-- userId sauvegardé invalide ou serveur injoignable → re-pairing
			state.userId = nil
			state.username = nil
			pcall(function() plugin:SetSetting(SETTING_PAIRED_USER, "") end)
			pcall(function() plugin:SetSetting(SETTING_PAIRED_NAME, "") end)
			showPairingScreen()
		end
	end)
end


-- ═══════════════════════════════ BOOTSTRAP ════════════════════════════
local _isEditMode = (function()
	local ok, result = pcall(function() return RunService:IsEdit() end)
	return ok and result == true
end)()

if _isEditMode then
	-- DataModel Edit : on construit l'UI
	toolbar = plugin:CreateToolbar("LearnBlox Studio")
	toggleBtn = toolbar:CreateButton("LearnBlox Studio", "Ouvrir LearnBlox Studio", PLUGIN_ICON, "LearnBloxStudio")
	toggleBtn.ClickableWhenViewportHidden = true
	-- Largeur mini 300 : en dessous, l'en-tête (logo + wordmark + 3 actions)
	-- et les chips de stats se chevaucheraient.
	local widgetInfo = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Right, false, false, 360, 680, 300, 460)
	-- L'id reste "LearnBloxPanelV3" malgré le renommage en LearnBlox Studio :
	-- Roblox y attache la position et la taille mémorisées du dock. Le changer
	-- réinitialiserait la fenêtre de tous les utilisateurs existants.
	widget = plugin:CreateDockWidgetPluginGui("LearnBloxPanelV3", widgetInfo)
	widget.Title = "LearnBlox Studio"
	widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling

	toggleBtn.Click:Connect(function()
		widget.Enabled = not widget.Enabled
		if widget.Enabled and #widget:GetChildren() == 0 then init() end
	end)

	if widget.Enabled then init() end

	-- ── Veille du script en cours ──
	-- Bloxi relit le script ouvert et signale les pièges classiques. Analyse
	-- 100 % locale : rien ne part sur le réseau, aucun quota IA consommé.
	task.spawn(function()
		local dernierTexte, dernierChangement = nil, 0
		local dejaDit = {}          -- "nomScript|règle" → déjà signalé
		local dernierMessage = 0    -- pour espacer les remarques

		while true do
			task.wait(2)
			-- Rien à faire si le panneau est fermé ou le compte non lié.
			if widget and widget.Enabled and state.userId then
				local ok = pcall(function()
					local doc = ScriptEditorService:FindScriptDocument(nil)
					if not doc then dernierTexte = nil return end
					local scr = doc:GetScript()
					-- Un script d'exercice est corrigé par le validateur, pas ici.
					if not scr or _isExerciseScript(scr) then dernierTexte = nil return end

					local texte = doc:GetText()
					if texte ~= dernierTexte then
						-- Toujours en train d'écrire : on attend la pause.
						dernierTexte = texte
						dernierChangement = os.clock()
						return
					end

					-- Pause d'au moins 6 s, et pas deux remarques coup sur coup.
					if os.clock() - dernierChangement < 6 then return end
					if os.clock() - dernierMessage < 45 then return end

					-- Le type du script décide de plusieurs règles : LocalPlayer
					-- côté serveur, DataStore côté client, FireServer/FireClient…
					local regle, message, ligne = analyseScript(texte, { className = scr.ClassName })
					if not regle then return end

					local cle = scr.Name .. "|" .. regle
					if dejaDit[cle] then return end   -- signalé, on n'insiste pas
					dejaDit[cle] = true
					dernierMessage = os.clock()

					local ou = ligne and (" (ligne " .. ligne .. ")") or ""
					state.bloxiChat = state.bloxiChat or {}
					state.bloxiChat[#state.bloxiChat + 1] = {
						role = "assistant",
						content = "👀 Je regarde **" .. scr.Name .. "**" .. ou .. " :\n\n" .. message,
					}
					-- Le panneau ne se rafraîchit que s'il est sur l'onglet Bloxi ;
					-- sinon le message attend sagement dans l'historique.
					pcall(function() toast("Bloxi a une remarque sur ton script", "info") end)
					if _refreshBloxiFeed then pcall(_refreshBloxiFeed) end
				end)
				if not ok then dernierTexte = nil end
			end
		end
	end)

	-- Heartbeat : ping le serveur toutes les 5s tant qu'on est appairé.
	-- Permet au site de détecter le plugin, ET au plugin de détecter une
	-- déconnexion déclenchée depuis le site (studio_linked = false).
	task.spawn(function()
		while true do
			task.wait(5)
			if state.userId then
				local me = api("GET", "/me")
				-- Stats fraîches pour le prochain rendu de l'en-tête. Le mode
				-- d'accès peut changer depuis le site : on le resuit ici.
				if type(me) == "table" and me.username then
					state.streak = tonumber(me.streak) or state.streak
					state.bestStreak = tonumber(me.bestStreak) or state.bestStreak
					state.premium = me.premium == true
					state.dev = me.dev == true
					if me.moduleAccess then
						local newMode = me.moduleAccess == "free" and "free" or "progressive"
						if newMode ~= state.moduleAccess then
							state.moduleAccess = newMode
							state.modulesCache = nil -- le verrouillage affiché change
						end
					end
				end
				-- Déconnexion demandée depuis le site → retour au pairing
				if type(me) == "table" and me.linked == false and state.gen and widget and widget.Enabled then
					state.userId = nil
					state.username = nil
					pcall(function() plugin:SetSetting(SETTING_PAIRED_USER, "") end)
					pcall(function() plugin:SetSetting(SETTING_PAIRED_NAME, "") end)
					pcall(function() toast("Compte déconnecté depuis le site.", "info") end)
					pcall(function() showPairingScreen() end)
				end
			end
		end
	end)

elseif (function() local ok, r = pcall(function() return RunService:IsRunning() and RunService:IsServer() end); return ok and r end)() then
	-- DataModel serveur d'un playtest : restaurer le userId pour les appels API
	local _savedId = select(2, pcall(function() return plugin:GetSetting(SETTING_PAIRED_USER) end))
	if _savedId and type(_savedId) == "string" and #_savedId > 0 then
		state.userId = _savedId
	end
	-- Validation exercice OU projet selon le contexte actif
	startPlaytestValidation()
	startProjectPlaytestValidation()
end
