File size: 18,083 Bytes
4aa6677
6cefb3d
4aa6677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6cefb3d
4aa6677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
495d60d
 
4aa6677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
"""
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 "")