Vortex-Flux / src /core /theme.py
jaimalklyde's picture
Claude Sonnet 5.5
feat(ui): base du benefice AccordΓ© et theme Gotham par defaut
6cefb3d
Raw History Blame Contribute Delete
18.1 kB
"""
THEMES VORTEX β€” NOIR (defaut, Gotham / poste de commandement) et BLEU
======================================================================
Le theme BLEU est celui de .streamlit/config.toml et de toutes les couleurs ecrites dans le code.
Le theme NOIR n'est PAS une seconde copie du style : c'est une table de correspondance
BLEU -> NOIR appliquee automatiquement, au moment du rendu, a :
- tout HTML/CSS injecte (st.markdown / st.html / components.html, y compris le grand bloc CSS
de streamlit_app.py, les bandeaux, l'overlay, le graphe Ontology...) ;
- toutes les figures Plotly (st.plotly_chart).
Les composants natifs Streamlit (fond, barre laterale, champs, listes, onglets, boutons,
alertes, toasts...) recoivent leurs couleurs du bloc CSS_CHROME, ecrit en couleurs BLEU puis
converti de la meme facon : une seule source de style pour les deux themes.
Le choix est memorise par navigateur (cookie vortex_theme, 1 an).
Limite : l'interieur des tableaux st.dataframe est dessine sur un canvas avec les couleurs de
config.toml (fond #0a0e12, quasi noir) ; seul leur cadre suit le theme.
"""
import json
import re
import streamlit as st
THEMES = ("BLEU", "NOIR")
DEFAUT = "NOIR"
NOM_COOKIE = "vortex_theme"
CLE_SESSION = "vx_theme"
# ── Correspondance des couleurs BLEU -> NOIR ────────────────────────────────
# Fonds bleu-nuit -> noirs ; gris bleutes -> gris neutres ; accent bleu -> ambre (selection,
# element actif, comme les postes de commandement) ; etats (vert / orange / rouge) desatures.
HEX_NOIR = {
# fonds
"#0a0e12": "#000000", "#0d1117": "#030303", "#0f141a": "#080808", "#161b22": "#0e0e0e",
"#1a2a4a": "#161616", "#21262d": "#141414", "#30363d": "#262626",
# gris / textes
"#2a3a4a": "#2b2b2b", "#3a4a5a": "#3a3a3a", "#4a5a6a": "#4c4c4c", "#5a6a7a": "#6a6a6a",
"#6a7a8a": "#777777", "#7a8a9a": "#878787", "#8a9aaa": "#999999", "#8b949e": "#8f8f8f",
"#a8b8c8": "#c4c4c4", "#c9d1d9": "#dedede", "#e6edf3": "#f0f0f0",
# accent
"#58a6ff": "#f2c94c", "#388bfd": "#d9ad2f", "#1e88e5": "#e0b43c", "#79c0ff": "#f6d77a",
# etats
"#54bd4b": "#6fcf97", "#2ecc71": "#6fcf97", "#3fb950": "#6fcf97",
"#f39c12": "#f2994a", "#e67e22": "#e8893a", "#f0b90b": "#f2c94c",
"#c0392b": "#d9534f", "#e74c3c": "#e05a4f", "#f85149": "#e05a4f",
# grilles et axes Plotly (modeles par defaut) : bleutes -> neutres
"#283442": "#1c1c1c", "#506784": "#3c3c3c", "#2a3f5f": "#cfcfcf", "#ebf0f8": "#1c1c1c", "#e5ecf6": "#0a0a0a",
}
RGB_NOIR = {
(10, 14, 18): (0, 0, 0), (13, 17, 23): (3, 3, 3), (15, 20, 26): (8, 8, 8), (22, 27, 34): (14, 14, 14),
(80, 100, 120): (90, 90, 90), (100, 120, 140): (110, 110, 110), (60, 80, 100): (70, 70, 70),
(50, 65, 80): (55, 55, 55), (168, 184, 200): (196, 196, 196), (48, 54, 61): (38, 38, 38),
(88, 166, 255): (242, 201, 76), (30, 136, 229): (224, 180, 60),
(243, 156, 18): (242, 153, 74), (192, 57, 43): (217, 83, 79), (231, 76, 60): (224, 90, 79),
(84, 189, 75): (111, 207, 151), (46, 204, 113): (111, 207, 151),
}
_RE_HEX = re.compile(r"#[0-9a-fA-F]{6}\b")
_RE_RGB = re.compile(r"rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*[\d.]+\s*)?\)", re.IGNORECASE)
def actif() -> str:
try:
valeur = st.session_state.get(CLE_SESSION)
except Exception:
valeur = None
return valeur if valeur in THEMES else DEFAUT
def est_noir() -> bool:
return actif() == "NOIR"
def convertir(texte, theme=None) -> str:
"""Texte (HTML, CSS, JSON) avec les couleurs du theme. BLEU : inchange."""
if (theme or actif()) != "NOIR" or not isinstance(texte, str) or not texte:
return texte
def _hex(m):
return HEX_NOIR.get(m.group(0).lower(), m.group(0))
def _rgb(m):
cle = tuple(int(x) for x in m.groups()[:3])
if cle not in RGB_NOIR:
return m.group(0)
r, g, b = RGB_NOIR[cle]
alpha = m.group(4)
return f"rgba({r}, {g}, {b}, {alpha.strip(' ,')})" if alpha else f"rgb({r}, {g}, {b})"
return _RE_RGB.sub(_rgb, _RE_HEX.sub(_hex, texte))
def convertir_figure(fig, theme=None):
"""Figure Plotly aux couleurs du theme (copie ; l'original n'est pas modifie)."""
if (theme or actif()) != "NOIR" or fig is None or not hasattr(fig, "to_json"):
return fig
try:
import plotly.io as pio
return pio.from_json(convertir(fig.to_json(), "NOIR"))
except Exception:
return fig
# ── Installation : conversion automatique au rendu ──────────────────────────
def installer():
"""Branche la conversion sur markdown / html / components.html / plotly_chart. Idempotent."""
from streamlit.delta_generator import DeltaGenerator
import streamlit.components.v1 as components_v1
if getattr(DeltaGenerator, "_vortex_theme_installe", False):
return
md, ht, pc = DeltaGenerator.markdown, DeltaGenerator.html, DeltaGenerator.plotly_chart
def _markdown(self, body, *args, **kwargs):
if kwargs.get("unsafe_allow_html") or (len(args) > 0 and args[0] is True):
body = convertir(body)
return md(self, body, *args, **kwargs)
def _html(self, body, *args, **kwargs):
return ht(self, convertir(body) if isinstance(body, str) else body, *args, **kwargs)
def _plotly(self, figure_or_data, *args, **kwargs):
return pc(self, convertir_figure(figure_or_data), *args, **kwargs)
DeltaGenerator.markdown, DeltaGenerator.html, DeltaGenerator.plotly_chart = _markdown, _html, _plotly
st.markdown = lambda body, *a, **k: DeltaGenerator.markdown(st._main, body, *a, **k)
st.html = lambda body, *a, **k: DeltaGenerator.html(st._main, body, *a, **k)
st.plotly_chart = lambda fig, *a, **k: DeltaGenerator.plotly_chart(st._main, fig, *a, **k)
composant = components_v1.html
def _composant(html_texte, *args, **kwargs):
return composant(convertir(html_texte), *args, **kwargs)
components_v1.html = _composant
DeltaGenerator._vortex_theme_installe = True
def initialiser():
"""Au debut de chaque execution : theme de la session, sinon celui du cookie du navigateur."""
if st.session_state.get(CLE_SESSION) in THEMES:
return
try:
cookie = st.context.cookies.get(NOM_COOKIE)
except Exception:
cookie = None
st.session_state[CLE_SESSION] = cookie if cookie in THEMES else DEFAUT
def selecteur(zone=None):
"""Interrupteur BLEU / NOIR (barre laterale). Le choix est ecrit dans un cookie (1 an)."""
zone = zone or st.sidebar
# Cookie ecrit a l'execution SUIVANT le changement : un script rendu juste avant st.rerun()
# n'atteint jamais le navigateur.
a_poser = st.session_state.pop("_vx_cookie_a_poser", None)
if a_poser in THEMES:
valeur = json.dumps(f"{NOM_COOKIE}={a_poser}; Path=/; Max-Age=31536000; Secure; SameSite=Lax")
import streamlit.components.v1 as components_v1
components_v1.html(f"<script>try {{ window.parent.document.cookie = {valeur}; }} catch (e) {{}}</script>",
height=0)
noir = zone.toggle("MODE NOIR Β· GOTHAM", value=est_noir(), key="vx_theme_toggle",
help="Thème noir poste de commandement. Désactivé : thème bleu habituel.")
choix = "NOIR" if noir else "BLEU"
if choix != st.session_state.get(CLE_SESSION):
st.session_state[CLE_SESSION] = choix
st.session_state["_vx_cookie_a_poser"] = choix
st.rerun()
# ── Style des composants natifs (ecrit en couleurs BLEU, converti pour NOIR) ─
CSS_CHROME = """
<style>
:root {
--vx-bg: #0a0e12; --vx-panel: #0f141a; --vx-panel-2: #161b22; --vx-line: rgba(80, 100, 120, 0.3);
--vx-text: #c9d1d9; --vx-muted: #8b949e; --vx-accent: #58a6ff;
--vx-ok: #54bd4b; --vx-info: #58a6ff; --vx-warn: #f39c12; --vx-err: #e74c3c;
--vx-mono: 'JetBrains Mono', 'Courier New', monospace;
}
/* ── Fonds ─────────────────────────────────────────────── */
.stApp, [data-testid="stAppViewContainer"], [data-testid="stMain"], [data-testid="stHeader"],
[data-testid="stBottom"] > div, [data-testid="stBottomBlockContainer"] { background-color: var(--vx-bg) !important; }
[data-testid="stSidebar"], [data-testid="stSidebar"] > div:first-child {
background-color: var(--vx-panel) !important; border-right: 1px solid var(--vx-line) !important; }
[data-testid="stHeader"] { border-bottom: 1px solid var(--vx-line) !important; }
/* ── Champs de saisie, listes, dates, zone de chat ─────── */
[data-testid="stTextInputRootElement"], [data-testid="stTextAreaRootElement"], [data-testid="stNumberInputContainer"],
[data-testid="stSelectbox"] [role="group"], [data-testid="stMultiSelect"] [role="group"],
[data-testid="stDateInput"] [role="group"], [data-testid="stDateInputField"], [data-testid="stChatInput"],
[data-testid="stChatInputTextArea"], [data-testid="stFileUploaderDropzone"], [data-testid="stChatInput"] > div {
background-color: var(--vx-bg) !important; border-color: var(--vx-line) !important; border-radius: 0 !important; color: var(--vx-text) !important; }
[data-testid="stTextInputField"], [data-testid="stNumberInputField"], textarea, input { color: var(--vx-text) !important; }
[data-testid="stTextInputRootElement"]:focus-within, [data-testid="stTextAreaRootElement"]:focus-within,
[data-testid="stNumberInputContainer"]:focus-within, [data-testid="stSelectbox"] [role="group"]:focus-within,
[data-testid="stMultiSelect"] [role="group"]:focus-within, [data-testid="stChatInput"]:focus-within {
border-color: var(--vx-accent) !important; box-shadow: none !important; }
[role="listbox"], [role="dialog"], [role="menu"], [data-testid="stPopoverBody"], [data-testid="stTooltipContent"] {
background-color: var(--vx-panel) !important; border: 1px solid var(--vx-line) !important; border-radius: 0 !important; color: var(--vx-text) !important; }
[role="option"][aria-selected="true"], [role="option"]:hover, [role="option"][data-focused="true"] {
background-color: rgba(88, 166, 255, 0.12) !important; color: var(--vx-accent) !important; }
[data-testid="stMultiSelect"] [data-tag] { background-color: rgba(88, 166, 255, 0.12) !important;
color: var(--vx-accent) !important; border: 1px solid rgba(88, 166, 255, 0.4) !important; border-radius: 0 !important; }
/* ── Boutons ────────────────────────────────────────────── */
[data-testid^="stBaseButton-secondary"], [data-testid="stBaseButton-secondaryFormSubmit"], [data-testid="stDownloadButton"] button {
background-color: var(--vx-panel) !important; color: var(--vx-text) !important; border: 1px solid var(--vx-line) !important;
border-radius: 0 !important; font-family: var(--vx-mono) !important; text-transform: uppercase; letter-spacing: 1.5px; }
[data-testid^="stBaseButton-secondary"]:hover, [data-testid="stDownloadButton"] button:hover {
border-color: var(--vx-accent) !important; color: var(--vx-accent) !important; }
[data-testid^="stBaseButton-primary"] {
background-color: rgba(88, 166, 255, 0.12) !important; color: var(--vx-accent) !important; border: 1px solid var(--vx-accent) !important;
border-radius: 0 !important; font-family: var(--vx-mono) !important; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 700 !important; }
[data-testid^="stBaseButton-primary"]:hover { background-color: var(--vx-accent) !important; color: var(--vx-bg) !important; }
[data-testid^="stBaseButton-primary"] p, [data-testid^="stBaseButton-secondary"] p { color: inherit !important; }
/* ── Onglets, expanders, conteneurs, metriques ──────────── */
[data-testid="stTab"] p { font-family: var(--vx-mono) !important; text-transform: uppercase; letter-spacing: 1.5px; font-size: 0.78rem !important; color: var(--vx-muted) !important; }
[data-testid="stTab"][data-selected="true"] p { color: var(--vx-accent) !important; }
[data-testid="stTab"][data-selected="true"] { box-shadow: inset 0 -2px 0 var(--vx-accent) !important; }
[data-testid="stExpander"] details { background-color: var(--vx-panel) !important; border: 1px solid var(--vx-line) !important; border-radius: 0 !important; }
[data-testid="stExpander"] summary p { font-family: var(--vx-mono) !important; text-transform: uppercase; letter-spacing: 1.5px; font-size: 0.75rem !important; }
[data-testid="stExpander"] summary:hover p, [data-testid="stExpander"] summary:hover [data-testid="stIconMaterial"] { color: var(--vx-accent) !important; }
[data-testid="stVerticalBlockBorderWrapper"], [data-testid="stForm"], [data-testid="stVerticalBlock"] { border-color: var(--vx-line) !important; border-radius: 0 !important; }
[data-testid="stMetric"] { background-color: var(--vx-panel); border: 1px solid var(--vx-line); border-left: 2px solid var(--vx-accent); padding: 10px 14px; }
[data-testid="stMetricLabel"] p { font-family: var(--vx-mono) !important; text-transform: uppercase; letter-spacing: 1.5px; font-size: 0.68rem !important; color: var(--vx-muted) !important; }
[data-testid="stMetricValue"] { font-family: var(--vx-mono) !important; color: var(--vx-text) !important; }
[data-testid="stDataFrame"], [data-testid="stTable"] { border: 1px solid var(--vx-line) !important; }
[data-testid="stCode"], [data-testid="stCode"] pre { background-color: var(--vx-bg) !important; border: 1px solid var(--vx-line) !important; border-radius: 0 !important; }
[data-testid="stCode"] code { font-family: var(--vx-mono) !important; color: var(--vx-text) !important; }
[data-testid="stProgressBarTrack"] { background-color: var(--vx-panel-2) !important; border-radius: 0 !important; }
[data-testid="stProgressBarTrack"] > div { background-color: var(--vx-accent) !important; border-radius: 0 !important; }
[data-testid="stChatMessage"] { background-color: var(--vx-panel) !important; border: 1px solid var(--vx-line); border-radius: 0 !important; }
hr { border-color: var(--vx-line) !important; }
[data-testid="stCaptionContainer"], [data-testid="stCaptionContainer"] p { color: var(--vx-muted) !important; }
/* ── Alertes : st.success / info / warning / error / exception ─────────────
Bandeau tactique : panneau sombre, liserΓ© gauche de la couleur de l'etat, etiquette. */
[data-testid="stAlertContainer"] {
--vx-k: var(--vx-info); background-color: var(--vx-panel) !important; color: var(--vx-text) !important;
border: 1px solid var(--vx-line) !important; border-left: 3px solid var(--vx-k) !important; border-radius: 0 !important;
padding: 10px 14px !important; font-family: var(--vx-mono) !important; }
[data-testid="stAlertContainer"]:has([data-testid="stAlertContentSuccess"]) { --vx-k: var(--vx-ok); }
[data-testid="stAlertContainer"]:has([data-testid="stAlertContentWarning"]) { --vx-k: var(--vx-warn); }
[data-testid="stAlertContainer"]:has([data-testid="stAlertContentError"]) { --vx-k: var(--vx-err); }
[data-testid^="stAlertContent"] p, [data-testid^="stAlertContent"] li { color: var(--vx-text) !important; font-family: var(--vx-mono) !important; font-size: 0.8rem !important; }
[data-testid^="stAlertContent"]::before {
display: block; margin-bottom: 4px; font-family: var(--vx-mono); font-size: 0.62rem; font-weight: 800;
letter-spacing: 2.5px; text-transform: uppercase; color: var(--vx-k); }
[data-testid="stAlertContentSuccess"]::before { content: "β–  CONFIRMATION"; }
[data-testid="stAlertContentInfo"]::before { content: "β–  INFORMATION"; }
[data-testid="stAlertContentWarning"]::before { content: "β–² AVERTISSEMENT"; }
[data-testid="stAlertContentError"]::before { content: "[ X ] ALERTE"; }
[data-testid="stException"] [data-testid="stAlertContentError"]::before { content: "[ X ] EXCEPTION SYSTEME"; }
[data-testid="stExceptionMessage"], [data-testid="stExceptionTraceRow"] { font-family: var(--vx-mono) !important; color: var(--vx-text) !important; }
[data-testid="stAlertContainer"] svg, [data-testid="stAlertContainer"] [data-testid="stIconMaterial"] { color: var(--vx-k) !important; }
/* ── Toasts ─────────────────────────────────────────────── */
[data-testid="stToast"] { background-color: var(--vx-panel) !important; color: var(--vx-text) !important;
border: 1px solid var(--vx-line) !important; border-left: 3px solid var(--vx-accent) !important; border-radius: 0 !important;
font-family: var(--vx-mono) !important; }
[data-testid="stToast"] p { color: var(--vx-text) !important; font-family: var(--vx-mono) !important; }
/* ── Barres de defilement ───────────────────────────────── */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--vx-bg); }
::-webkit-scrollbar-thumb { background: var(--vx-panel-2); border: 1px solid var(--vx-line); }
::-webkit-scrollbar-thumb:hover { background: var(--vx-accent); }
</style>
"""
# Uniquement en NOIR : typographie "mission suite" (titres fins, grandes capitales espacees).
CSS_NOIR = """
<style>
:root { --vx-info: #7fa7c9; } /* info : bleu acier, distinct de l'accent ambre */
.stApp h1, .stApp h2, .stApp h3 {
font-family: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif !important; font-weight: 300 !important;
text-transform: uppercase; letter-spacing: 0.22em !important; color: #f0f0f0 !important; }
.stApp h1 strong, .stApp h2 strong, .stApp h3 strong { font-weight: 700 !important; }
[data-testid="stSidebar"] { background-color: #050505 !important; }
[data-testid="stHeader"] { background-color: #000000 !important; }
</style>
"""
def css() -> str:
"""Feuille de style du theme actif (a injecter une fois par execution, apres le CSS principal)."""
return convertir(CSS_CHROME) + (CSS_NOIR if est_noir() else "")