Source code for best_engine_ai_helper.gui

"""
Best Engine AI Helper — single-page GUI.

This module holds the self-contained HTML document served by the FastAPI app at
``GET /gui`` (see :mod:`best_engine_ai_helper.api`). One file, Tailwind via CDN,
vanilla JavaScript — no build step, no framework, no npm, matching the rest of
the AI Helpers suite (see audio-helper's GUI.md).

The page is offered in French (default) and English. Both are rendered from a
single template plus a per-language strings table by :func:`render_gui`; a
header link switches between them (``/gui`` for French, ``/gui?lang=en`` for
English). The JSON API the page calls is language-neutral, so only the labels
differ. ``GUI_HTML`` is the French render, kept as a module constant for
callers and tests that want the default page directly.

Visual language matches the sprezzature-figures gallery
(https://harchaoui.org/warith/sprezzature/figures.html): Roboto / Roboto
Serif / Roboto Mono, the #007aff brand blue, a neutral gray scale, and a
manual light/dark toggle persisted under a ``data-color-scheme`` attribute
(not hotlinked — fonts are self-served via Google Fonts, not the reference
site's assets).

Author
------
Warith Harchaoui <warith.harchaoui@deraison.ai>
"""

from __future__ import annotations

import html
import json

# ---------------------------------------------------------------------------
# Per-language label tables
# ---------------------------------------------------------------------------
# `html` keys fill {{TOKEN}} placeholders in the markup; `js` keys are emitted
# as a `const T = {...}` object the page's script reads, so every user-facing
# string — HTML and JavaScript alike — lives here and nowhere else.

_STRINGS: dict[str, dict[str, dict[str, str]]] = {
    "fr": {
        "html": {
            "LANG": "fr",
            "META_DESC": (
                "Caractéristiques matérielles de cette machine et meilleur "
                "moteur local (LLM / VLM) pour une tâche donnée."
            ),
            "SKIP": "Aller au contenu",
            "NAV_ARIA": "Principal",
            "GITHUB": "⭐️ sur GitHub",
            "THEME_ARIA": "Changer de thème",
            "LANG_HREF": "/gui?lang=en",
            "LANG_LABEL": "EN",
            "LANG_ARIA": "English version",
            "HERO_TITLE": "Meilleur moteur local",
            "HERO_P": (
                "Caractéristiques de cette machine, et le meilleur moteur local "
                "(LLM / VLM) pour la tâche que vous décrivez."
            ),
            "SYS_H2": "Caractéristiques système",
            "REFRESH": "Rafraîchir",
            "DETECTING": "Détection en cours…",
            "TASK_H2": "Décrire la tâche",
            "TASK_P": (
                "Une phrase suffit — les mots-clés visuels ajoutent un VLM à la "
                "recommandation."
            ),
            "TASK_LABEL": "Description de la tâche",
            "TASK_PLACEHOLDER": (
                "ex. « rédiger des fiches produit et vérifier la qualité de photos »"
            ),
            "RUN_BTN": "Recommander le(s) meilleur(s) moteur(s)",
            "HEADROOM": "Marge mémoire",
            "FOOTER_NOTE": "local uniquement, aucune télémétrie",
        },
        "js": {
            "detecting": "Détection en cours…",
            "detectFail": "Détection impossible : ",
            "fPlatform": "Plateforme",
            "fVendor": "Fournisseur",
            "fChip": "Puce / accélérateur",
            "poolUnified": "Mémoire unifiée",
            "poolVram": "VRAM",
            "poolRam": "RAM système",
            "fBandwidth": "Bande passante mémoire",
            "unknown": "inconnue",
            "fBudget": "Budget modèle utilisable",
            "gb": "Go",
            "gbs": "Go/s",
            "analyzing": "Analyse de la tâche et du matériel…",
            "done": "Terminé.",
            "recFail": "Échec de la recommandation : ",
            "error": "Erreur.",
            "yes": "oui",
            "no": "non",
            "best": "Meilleur",
            "axis": "axe :",
            "overBudget": "dépasse le budget mémoire — sera lent",
            "notStructured": "pas de sortie JSON structurée — inadapté aux tâches à schéma",
            "lighterAlt": "Alternative plus légère :",
            "score": "score",
            "noCandidate": "Aucun candidat trouvé.",
            "allCandidates": "Tous les candidats",
            "thStructured": "structuré",
            "thModel": "modèle",
            "thRam": "Go RAM",
            "thScore": "score",
            "thFits": "tient",
            "thTps": "tok/s",
            "task": "Tâche :",
            "genericAssistant": "assistant texte généraliste",
            "matched": "Mots-clés détectés :",
            "needs": "Besoins :",
            "tps": "tok/s",
        },
    },
    "en": {
        "html": {
            "LANG": "en",
            "META_DESC": (
                "This machine's hardware characteristics and the best local "
                "engine (LLM / VLM) for a given task."
            ),
            "SKIP": "Skip to content",
            "NAV_ARIA": "Main",
            "GITHUB": "⭐️ on GitHub",
            "THEME_ARIA": "Toggle theme",
            "LANG_HREF": "/gui",
            "LANG_LABEL": "FR",
            "LANG_ARIA": "Version française",
            "HERO_TITLE": "Best local engine",
            "HERO_P": (
                "This machine's characteristics, and the best local engine "
                "(LLM / VLM) for the task you describe."
            ),
            "SYS_H2": "System characteristics",
            "REFRESH": "Refresh",
            "DETECTING": "Detecting…",
            "TASK_H2": "Describe the task",
            "TASK_P": (
                "One sentence is enough — visual keywords add a VLM to the "
                "recommendation."
            ),
            "TASK_LABEL": "Task description",
            "TASK_PLACEHOLDER": (
                'e.g. "write product descriptions and check photo quality"'
            ),
            "RUN_BTN": "Recommend the best engine(s)",
            "HEADROOM": "Memory headroom",
            "FOOTER_NOTE": "local only, no telemetry",
        },
        "js": {
            "detecting": "Detecting…",
            "detectFail": "Detection failed: ",
            "fPlatform": "Platform",
            "fVendor": "Vendor",
            "fChip": "Chip / accelerator",
            "poolUnified": "Unified memory",
            "poolVram": "VRAM",
            "poolRam": "System RAM",
            "fBandwidth": "Memory bandwidth",
            "unknown": "unknown",
            "fBudget": "Usable model budget",
            "gb": "GB",
            "gbs": "GB/s",
            "analyzing": "Analyzing task and hardware…",
            "done": "Done.",
            "recFail": "Recommendation failed: ",
            "error": "Error.",
            "yes": "yes",
            "no": "no",
            "best": "Best",
            "axis": "axis:",
            "overBudget": "exceeds the memory budget — will be slow",
            "notStructured": "no structured JSON output — unfit for schema-driven tasks",
            "lighterAlt": "Lighter alternative:",
            "score": "score",
            "noCandidate": "No candidate found.",
            "allCandidates": "All candidates",
            "thStructured": "structured",
            "thModel": "model",
            "thRam": "GB RAM",
            "thScore": "score",
            "thFits": "fits",
            "thTps": "tok/s",
            "task": "Task:",
            "genericAssistant": "general-purpose text assistant",
            "matched": "Detected keywords:",
            "needs": "Needs:",
            "tps": "tok/s",
        },
    },
}

# Default language when the query string names none or an unknown one.
_DEFAULT_LANG = "fr"


# The document template. Human-facing text is tokenised as {{TOKEN}} (HTML) or
# read from the injected `T` object (JavaScript), so this markup is shared by
# every language.
_GUI_TEMPLATE: str = r"""<!doctype html>
<html lang="{{LANG}}" data-color-scheme="light">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Best Engine AI Helper</title>
  <meta name="description" content="{{META_DESC}}" />
  <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
  <meta name="theme-color" content="#0B0B0C" media="(prefers-color-scheme: dark)" />

  <link rel="icon" href="/static/icons/favicon.ico" sizes="any" />
  <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png" />
  <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png" />
  <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png" />
  <link rel="manifest" href="/static/site.webmanifest" />

  <!-- Same type family as sprezzature-figures: Roboto / Roboto Serif / Roboto Mono. -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&family=Roboto+Serif:wght@600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet" />

  <!-- Resolve light/dark before first paint (no flash), same scheme as the
       reference site but under this app's own storage key. -->
  <script>(function(){var s;try{s=localStorage.getItem('best-engine-theme');}catch(e){}
    if(!s)s=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';
    document.documentElement.setAttribute('data-color-scheme',s);})();</script>

  <!-- Tailwind via CDN: keeps the page a single self-contained file, no build. -->
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      darkMode: ["selector", '[data-color-scheme="dark"]'],
      theme: {
        extend: {
          fontFamily: {
            sans: ["Roboto", "system-ui", "sans-serif"],
            serif: ["Roboto Serif", "serif"],
            mono: ["Roboto Mono", "ui-monospace", "monospace"],
          },
          colors: {
            brand: {
              blue: "#007aff",
              bluedark: "#0a84ff",
              bluelight: "#cce4ff",
              navy: "#0a4da0",
            },
          },
        },
      },
    };
  </script>
  <style>
    @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
  </style>
</head>
<body class="bg-white text-neutral-900 antialiased dark:bg-[#0B0B0C] dark:text-neutral-100 font-sans">
  <a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-3 focus:left-3 focus:rounded-lg focus:bg-brand-blue focus:px-4 focus:py-2 focus:text-white">{{SKIP}}</a>

  <header class="sticky top-0 z-40 border-b border-neutral-200/70 bg-white/80 backdrop-blur dark:border-neutral-800 dark:bg-[#0B0B0C]/80">
    <nav class="mx-auto flex max-w-4xl items-center justify-between px-5 py-3" aria-label="{{NAV_ARIA}}">
      <span class="flex items-center gap-2 font-semibold tracking-tight">
        <img src="/static/icons/android-chrome-192x192.png" alt="" width="28" height="28" class="rounded-lg" />
        Best Engine AI Helper
      </span>
      <div class="flex items-center gap-3 text-sm">
        <a href="https://github.com/warith-harchaoui/best-engine-ai-helper"
           class="hidden rounded px-1 hover:text-brand-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue sm:inline">
          {{GITHUB}}
        </a>
        <a href="{{LANG_HREF}}" aria-label="{{LANG_ARIA}}"
           class="rounded px-1.5 py-1 font-mono text-xs font-semibold hover:text-brand-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue">
          {{LANG_LABEL}}
        </a>
        <button id="theme-toggle" type="button" aria-label="{{THEME_ARIA}}"
                class="rounded-full px-2 py-1 text-lg leading-none hover:bg-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue dark:hover:bg-neutral-800">
          🌞
        </button>
      </div>
    </nav>
  </header>

  <main id="main">
    <section class="mx-auto max-w-4xl px-5 py-14">
      <p class="font-mono text-sm text-brand-blue">best-engine-ai-helper</p>
      <h1 class="mt-1 font-serif text-4xl font-bold tracking-tight sm:text-5xl">{{HERO_TITLE}}</h1>
      <p class="mt-3 max-w-2xl text-base text-neutral-600 dark:text-neutral-300">
        {{HERO_P}}
      </p>
    </section>

    <!-- 1) Hardware snapshot -->
    <section class="border-t border-neutral-200/70 dark:border-neutral-800">
      <div class="mx-auto max-w-4xl px-5 py-10">
        <div class="mb-4 flex items-center justify-between">
          <h2 class="text-2xl font-bold tracking-tight">{{SYS_H2}}</h2>
          <button id="refresh-system"
                  class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium
                         hover:border-brand-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue
                         dark:border-neutral-600">
            {{REFRESH}}
          </button>
        </div>
        <div id="system-grid"
             class="grid grid-cols-2 gap-4 rounded-xl border border-neutral-200 bg-white p-5 text-sm shadow-sm
                    sm:grid-cols-3 dark:border-neutral-800 dark:bg-neutral-950">
          <p class="col-span-full text-neutral-500 dark:text-neutral-400">{{DETECTING}}</p>
        </div>
      </div>
    </section>

    <!-- 2) Task description -->
    <section class="border-t border-neutral-200/70 dark:border-neutral-800">
      <div class="mx-auto max-w-4xl px-5 py-10">
        <h2 class="text-2xl font-bold tracking-tight">{{TASK_H2}}</h2>
        <p class="mt-2 max-w-3xl text-neutral-600 dark:text-neutral-300">
          {{TASK_P}}
        </p>
        <div class="mt-6 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm dark:border-neutral-800 dark:bg-neutral-950">
          <label for="task" class="sr-only">{{TASK_LABEL}}</label>
          <textarea id="task" rows="3" placeholder="{{TASK_PLACEHOLDER}}"
                    class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900
                           focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue
                           dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100"></textarea>
          <div class="mt-4 flex flex-wrap items-center gap-3">
            <button id="run-recommend"
                    class="rounded-lg bg-brand-blue px-4 py-2 text-sm font-semibold text-white
                           hover:bg-brand-bluedark focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue
                           disabled:opacity-50">
              {{RUN_BTN}}
            </button>
            <label class="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-600
                          dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
              {{HEADROOM}}
              <input id="headroom" type="number" min="0.5" max="1" step="0.05" value="0.85"
                     class="w-16 rounded border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-900
                            focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-blue
                            dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-100" />
            </label>
            <span id="status" class="text-xs text-neutral-500 dark:text-neutral-400" role="status" aria-live="polite"></span>
          </div>
        </div>
      </div>
    </section>

    <!-- 3) Results -->
    <section id="results-section" class="border-t border-neutral-200/70 dark:border-neutral-800">
      <div class="mx-auto max-w-4xl px-5 py-10">
        <div id="results" class="space-y-4"></div>
      </div>
    </section>
  </main>

  <footer class="border-t border-neutral-200/70 py-10 dark:border-neutral-800">
    <div class="mx-auto max-w-4xl px-5 text-sm text-neutral-500 dark:text-neutral-400">
      <p>
        <a class="underline hover:text-brand-blue" href="https://github.com/warith-harchaoui/best-engine-ai-helper">best-engine-ai-helper</a>
        · {{FOOTER_NOTE}} ·
        <a class="underline hover:text-brand-blue" href="https://www.linkedin.com/in/warith-harchaoui/">Warith Harchaoui</a>
      </p>
    </div>
  </footer>

  <script type="module">
    // All user-facing strings for this render, injected from the server.
    const T = {{JS_T}};

    const $ = (id) => document.getElementById(id);
    const status = (msg) => { $("status").textContent = msg; };
    const fmt1 = (v) => (v === null || v === undefined ? "?" : Number(v).toFixed(1));
    const fmtTps = (v) => (v ? Math.round(v) : "-");

    // --- Theme toggle ------------------------------------------------------
    const themeBtn = $("theme-toggle");
    function currentTheme() {
      return document.documentElement.getAttribute("data-color-scheme") === "dark" ? "dark" : "light";
    }
    function applyThemeIcon() {
      themeBtn.textContent = currentTheme() === "dark" ? "🌙" : "🌞";
    }
    themeBtn.addEventListener("click", () => {
      const next = currentTheme() === "dark" ? "light" : "dark";
      document.documentElement.setAttribute("data-color-scheme", next);
      try { localStorage.setItem("best-engine-theme", next); } catch (e) {}
      applyThemeIcon();
    });
    applyThemeIcon();

    // --- 1) Hardware snapshot -------------------------------------------
    async function loadSystem() {
      $("system-grid").innerHTML =
        `<p class="col-span-full text-neutral-500 dark:text-neutral-400">${T.detecting}</p>`;
      try {
        const res = await fetch("/api/system");
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const info = await res.json();
        renderSystem(info);
      } catch (err) {
        $("system-grid").innerHTML =
          `<p class="col-span-full text-red-600 dark:text-red-400">${T.detectFail}${err}</p>`;
      }
    }

    function field(label, value) {
      return `
        <div>
          <dt class="text-xs text-neutral-500 dark:text-neutral-400">${label}</dt>
          <dd class="font-medium">${value}</dd>
        </div>`;
    }

    function renderSystem(info) {
      const mem = info.memory || {};
      const comp = info.compute || {};
      const pool = mem.unified_gb ?? mem.vram_gb ?? mem.ram_gb;
      const poolLabel = mem.unified_gb != null ? T.poolUnified
        : mem.vram_gb != null ? T.poolVram : T.poolRam;

      const cells = [
        field(T.fPlatform, info.platform ?? "?"),
        field(T.fVendor, info.chip_vendor ?? "?"),
        field(T.fChip, `${comp.chip ?? "?"} (${comp.accelerator ?? "?"})`),
        field(poolLabel, `${fmt1(pool)} ${T.gb}`),
        field(T.fBandwidth, comp.bandwidth_gbs ? `${Math.round(comp.bandwidth_gbs)} ${T.gbs}` : T.unknown),
        field(T.fBudget, `${fmt1(info.memory_budget_gb)} ${T.gb}`),
      ];
      $("system-grid").innerHTML = cells.join("");
    }

    $("refresh-system").addEventListener("click", loadSystem);
    loadSystem();

    // --- 2 & 3) Recommendation -------------------------------------------
    $("run-recommend").addEventListener("click", runRecommend);
    $("task").addEventListener("keydown", (e) => {
      if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) runRecommend();
    });

    // Deep link: /gui?task=... pre-fills the box and runs it on load, so a
    // recommendation is shareable by URL. ?lang is handled server-side.
    const urlTask = new URLSearchParams(location.search).get("task");
    if (urlTask) {
      $("task").value = urlTask;
      runRecommend();
    }

    async function runRecommend() {
      const task = $("task").value.trim() || null;
      const headroom = Number($("headroom").value) || 0.85;
      $("run-recommend").disabled = true;
      status(T.analyzing);
      $("results").innerHTML = "";
      try {
        const res = await fetch("/api/recommend", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ task, headroom }),
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
        const report = await res.json();
        renderResults(report);
        status(T.done);
      } catch (err) {
        $("results").innerHTML =
          `<p class="text-sm text-red-600 dark:text-red-400">${T.recFail}${err}</p>`;
        status(T.error);
      } finally {
        $("run-recommend").disabled = false;
      }
    }

    function candidateRow(r) {
      return `
        <tr class="border-t border-neutral-200 dark:border-neutral-800">
          <td class="py-1.5 pr-3 font-mono">${r.id}</td>
          <td class="py-1.5 pr-3">${fmt1(r.ram_gb)}</td>
          <td class="py-1.5 pr-3">${Math.round(r.score)}</td>
          <td class="py-1.5 pr-3">${r.fits ? T.yes : T.no}</td>
          <td class="py-1.5 pr-3">${r.structured_output === false ? T.no : T.yes}</td>
          <td class="py-1.5">${fmtTps(r.est_tokens_per_s)}</td>
        </tr>`;
    }

    function pill(text) {
      return `<span class="inline-flex items-center rounded-full border border-brand-blue/30 bg-brand-bluelight/30
                            px-3 py-1 font-mono text-sm font-semibold text-brand-navy
                            dark:border-brand-blue/40 dark:bg-brand-navy/20 dark:text-blue-300">${text}</span>`;
    }

    function kindCard(kind, block) {
      const chosen = block.chosen;
      const alt = block.lighter_alternative;
      const chosenHtml = chosen ? `
        <p class="flex flex-wrap items-center gap-2 text-sm">
          ${pill(chosen.id)}
          <span class="text-neutral-600 dark:text-neutral-300">
            ${fmt1(chosen.ram_gb)} ${T.gb}, ${T.score} ${Math.round(chosen.score)}, ~${fmtTps(chosen.est_tokens_per_s)} ${T.tps}
          </span>
          ${chosen.fits ? "" : `<span class="text-red-600 dark:text-red-400">${T.overBudget}</span>`}
          ${chosen.structured_output === false ? `<span class="text-amber-600 dark:text-amber-400">${T.notStructured}</span>` : ""}
        </p>
        ${alt ? `<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">${T.lighterAlt} <span class="font-mono">${alt.id}</span> — ${fmt1(alt.ram_gb)} ${T.gb}, ${T.score} ${Math.round(alt.score)}, ~${fmtTps(alt.est_tokens_per_s)} ${T.tps}.</p>` : ""}
      ` : `<p class="text-sm text-neutral-500 dark:text-neutral-400">${T.noCandidate}</p>`;

      return `
        <div class="overflow-hidden rounded-xl border border-neutral-200 bg-white p-5 shadow-sm dark:border-neutral-800 dark:bg-neutral-950">
          <h3 class="mb-3 text-lg font-bold tracking-tight">
            ${T.best} ${kind.toUpperCase()} <span class="font-mono text-sm font-normal text-neutral-500 dark:text-neutral-400">(${T.axis} ${block.axis})</span>
          </h3>
          ${chosenHtml}
          <details class="mt-4">
            <summary class="cursor-pointer text-xs font-medium text-neutral-500 hover:text-brand-blue dark:text-neutral-400">
              ${T.allCandidates} (${block.candidates.length})
            </summary>
            <div class="mt-2 overflow-x-auto">
              <table class="w-full text-left text-xs">
                <thead>
                  <tr class="text-neutral-500 dark:text-neutral-400">
                    <th class="pb-1.5 pr-3 font-medium">${T.thModel}</th>
                    <th class="pb-1.5 pr-3 font-medium">${T.thRam}</th>
                    <th class="pb-1.5 pr-3 font-medium">${T.thScore}</th>
                    <th class="pb-1.5 pr-3 font-medium">${T.thFits}</th>
                    <th class="pb-1.5 pr-3 font-medium">${T.thStructured}</th>
                    <th class="pb-1.5 font-medium">${T.thTps}</th>
                  </tr>
                </thead>
                <tbody>${block.candidates.map(candidateRow).join("")}</tbody>
              </table>
            </div>
          </details>
        </div>`;
    }

    function renderResults(report) {
      const task = report.task || {};
      const parts = [];

      parts.push(`
        <div class="rounded-xl border border-neutral-200 bg-neutral-50 p-4 text-sm dark:border-neutral-800 dark:bg-neutral-900">
          <p><span class="text-neutral-500 dark:text-neutral-400">${T.task}</span> ${task.input ? task.input : T.genericAssistant}</p>
          ${task.matched && task.matched.length ? `<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">${T.matched} ${task.matched.join(", ")}</p>` : ""}
          <p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">${T.needs} ${task.kinds.map((k) => k.toUpperCase()).join(", ")}</p>
        </div>`);

      const kindBlocks = Object.entries(report.recommendations || {});
      const gridClass = kindBlocks.length > 1 ? "mt-4 grid gap-4 sm:grid-cols-2" : "mt-4 grid gap-4";
      parts.push(`<div class="${gridClass}">${kindBlocks.map(([kind, block]) => kindCard(kind, block)).join("")}</div>`);

      $("results").innerHTML = parts.join("");
    }
  </script>
</body>
</html>
"""


[docs] def render_gui(lang: str = _DEFAULT_LANG) -> str: """ Render the single-page GUI in the requested language. Parameters ---------- lang : str Two-letter language code. ``"fr"`` (default) and ``"en"`` are supported; any other value falls back to French so an unknown ``?lang=`` never 500s. Returns ------- str The complete HTML document for that language. """ strings = _STRINGS.get(lang, _STRINGS[_DEFAULT_LANG]) page = _GUI_TEMPLATE for token, value in strings["html"].items(): # Escape every value: these labels land in attributes (placeholder, aria, # href) and text nodes alike, and some carry quotes (the EN placeholder) # that would otherwise break out of the attribute they sit in. page = page.replace("{{" + token + "}}", html.escape(value, quote=True)) # Emit the JS strings as a JSON literal so quotes/accents are escaped safely. page = page.replace("{{JS_T}}", json.dumps(strings["js"], ensure_ascii=False)) return page
# French render, kept as a module constant for the default page and for callers # and tests that want the markup without going through render_gui(). GUI_HTML: str = render_gui("fr")