diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py
new file mode 100755
index 0000000..38b60da
--- /dev/null
+++ b/script/addons/theme_leftover.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'un thème laisse derrière lui une fois désinstallé. Lecture seule.
+
+Décharger un thème retire ses copies de vues et rend au site sa configuration
+par défaut. Restent des pièces jointes portant son chemin — SCSS compilé,
+images téléversées dans ses dossiers — et parfois des vues dont la clé le
+nomme encore.
+
+Elles ne cassent rien tant que le module est parti : plus personne ne les
+inclut dans un bundle. Mais elles traversent toutes les migrations suivantes,
+et l'on finit par tomber sur un `/theme_x/static/...` dont le module n'existe
+plus nulle part, sans savoir si c'est grave.
+
+Rien n'est supprimé ici. Le contenu d'une pièce jointe peut être la seule
+trace d'une personnalisation, et c'est une décision, pas un ménage.
+
+Codes de sortie : 0 rien à signaler, 1 des restes, 2 l'outil a échoué.
+"""
+
+import argparse
+import os
+import subprocess
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+def run_psql(database, sql):
+ """Interroger la base en lecture seule, garantie côté serveur.
+
+ `default_transaction_read_only` est posé par le serveur pour toute la
+ session : ce n'est pas une promesse de l'outil, c'est PostgreSQL qui
+ refusera l'écriture même si le SQL en contenait une.
+ """
+ env = os.environ.copy()
+ env["PGOPTIONS"] = (
+ "-c default_transaction_read_only=on -c statement_timeout=30000"
+ )
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ [
+ "psql",
+ "-X",
+ "-w",
+ "-v",
+ "ON_ERROR_STOP=1",
+ "-d",
+ database,
+ "-tAF",
+ "|",
+ "-c",
+ sql,
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ raise RuntimeError(done.stderr.strip() or "psql failed")
+ return [line for line in done.stdout.splitlines() if line]
+
+
+def quote_literal(value):
+ return "'" + value.replace("'", "''") + "'"
+
+
+def collect(database, theme):
+ """Pièces jointes et vues qui nomment encore ce thème."""
+ like = quote_literal(f"%/{theme}/%")
+ key_like = quote_literal(f"{theme}.%")
+ attachments = run_psql(
+ database,
+ "SELECT id, COALESCE(url, name), create_date::date FROM ir_attachment"
+ f" WHERE url LIKE {like} ORDER BY id;",
+ )
+ views = run_psql(
+ database,
+ "SELECT id, key, website_id FROM ir_ui_view"
+ f" WHERE key LIKE {key_like} ORDER BY id;",
+ )
+ return attachments, views
+
+
+def render(theme, attachments, views):
+ if not attachments and not views:
+ return f"✅ -> {t('No leftover for theme')} '{theme}'.\n"
+ lines = []
+ if attachments:
+ lines.append(
+ f"ℹ {len(attachments)} {t('attachment(s) still under')}"
+ f" /{theme}/ :"
+ )
+ for row in attachments[:20]:
+ lines.append(f" - {row}")
+ if len(attachments) > 20:
+ lines.append(f" … {len(attachments) - 20} {t('more')}")
+ if views:
+ lines.append(
+ f"ℹ {len(views)} {t('view(s) whose key still names it')} :"
+ )
+ for row in views[:20]:
+ lines.append(f" - {row}")
+ if len(views) > 20:
+ lines.append(f" … {len(views) - 20} {t('more')}")
+ lines.append(
+ f" {t('Nothing was deleted: their content may be the only trace')}"
+ f" {t('of a customization. Read before removing.')}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description=("List what an uninstalled theme left behind (read-only).")
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-t", "--theme", required=True)
+ config = parser.parse_args(argv)
+ try:
+ attachments, views = collect(config.database, config.theme)
+ except RuntimeError as exc:
+ print(f"❌ {exc}")
+ return 2
+ print(render(config.theme, attachments, views))
+ return 1 if (attachments or views) else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/addons/uninstall_addons_theme.sh b/script/addons/uninstall_addons_theme.sh
new file mode 100755
index 0000000..5eafad4
--- /dev/null
+++ b/script/addons/uninstall_addons_theme.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Désinstaller un thème comme Odoo le fait lui-même.
+#
+# « ./run.sh --uninstall theme_x » retire le MODULE, pas le THÈME. Choisir un
+# thème (button_choose_theme, ce que pose --install-theme) fait DEUX choses :
+# copier ses vues et ressources dans chaque site, et écrire dans
+# user_values.scss une personnalisation qui DÉFINIT $o-theme-font-number et
+# ses trois voisines. Le chemin de retrait d'Odoo, _theme_remove(), défait les
+# deux.
+#
+# Le désinstaller sans lui laisse donc les copies, et surtout n'écrit jamais
+# ces définitions. Mesuré sur une migration réelle 12 -> 13 : le bundle
+# web.assets_frontend s'arrête sur « Undefined variable: $o-theme-font-number
+# ». La variable venait des fichiers option_font_body_* d'Odoo 12, supprimés
+# en 13.0 ; seul le thème la redéfinissait encore, et le retirer a mis à nu
+# une personnalisation figée depuis des années.
+#
+# Usage : ./script/addons/uninstall_addons_theme.sh [config]
+
+Red='\033[0;31m' # Red
+Color_Off='\033[0m' # Text Reset
+
+if [[ $# -lt 2 ]]; then
+ echo "Usage: $0 [config]"
+ exit 1
+fi
+
+DATABASE="$1"
+THEME="$2"
+CONFIG="${3:-./config.conf}"
+
+if [[ $# -eq 3 ]]; then
+ ./script/addons/check_addons_exist.py -m "$THEME" -c "$3"
+else
+ ./script/addons/check_addons_exist.py -m "$THEME"
+fi
+retVal=$?
+if [[ $retVal -ne 0 ]]; then
+ echo -e "${Red}Error${Color_Off} check_addons_exist.py into uninstall_addons_theme.sh"
+ exit 1
+fi
+
+echo "Unload theme '$THEME' from every website of BD '$DATABASE'"
+
+# Le déchargement passe par le shell : _theme_remove() n'a pas d'option de
+# ligne de commande, et l'écrire ici évite d'ajouter une option au fork pour
+# chaque version d'Odoo. Les journaux se mêlent à la sortie, d'où la
+# sentinelle : on ne conclut que sur ce qui la suit.
+./odoo_bin.sh shell -c "$CONFIG" -d "$DATABASE" --no-http --log-level=warn <