FajarHidaa commited on
Commit
eb183b9
Β·
verified Β·
1 Parent(s): 0f3ee58

Upload 2 files

Browse files
Files changed (2) hide show
  1. src/ui/components.py +345 -0
  2. src/ui/styles.py +522 -0
src/ui/components.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Human+ Reusable UI Components
3
+ Prinsip refactor: gunakan Streamlit native sebanyak mungkin.
4
+ HTML hanya untuk elemen dekoratif (hero logo, gradient) yang
5
+ tidak ada padanan native-nya.
6
+ """
7
+
8
+ import streamlit as st
9
+ from typing import Literal, Optional
10
+ from ui.theme import COLORS, BIOMARKER_STATUS, PROTOCOL_SECTIONS
11
+
12
+
13
+ # ════════════════════════════════════════════════
14
+ # HERO & NAVIGATION
15
+ # ════════════════════════════════════════════════
16
+
17
+ def render_hero(
18
+ subtitle: str = "Upload hasil lab Anda β†’ Dapatkan rekomendasi IV drip & protocol personal"
19
+ ):
20
+ """
21
+ Hero header β€” HTML dipertahankan hanya untuk gradient text logo
22
+ yang tidak bisa dibuat dengan st.title() biasa.
23
+ """
24
+ st.markdown(
25
+ """
26
+ <div style="text-align:center; padding: 32px 0 8px;">
27
+ <p style="
28
+ font-size:0.72rem; font-weight:600;
29
+ letter-spacing:0.2em; text-transform:uppercase;
30
+ color:#4a4a6a; margin-bottom:6px;
31
+ ">Human+ Bali Β· By Benjamin White</p>
32
+ <h1 style="
33
+ font-family:'DM Serif Display', Georgia, serif;
34
+ font-size:2.6rem; font-weight:900; line-height:1;
35
+ background: linear-gradient(135deg, #a78bfa, #38bdf8, #22d3ee);
36
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
37
+ background-clip: text; margin:0 0 8px;
38
+ ">Lab-to-Protocol</h1>
39
+ </div>
40
+ """,
41
+ unsafe_allow_html=True,
42
+ )
43
+ st.caption(f"🧬 {subtitle}")
44
+ st.write("")
45
+
46
+
47
+ def render_step_indicator(current_step: int):
48
+ """
49
+ 3-step progress indicator menggunakan st.columns + native markdown.
50
+ Tidak ada HTML kompleks β€” hanya emoji dan teks.
51
+ """
52
+ steps = ["πŸ“€ Upload", "πŸ”¬ Analisis", "πŸ“‹ Protocol"]
53
+ cols = st.columns(3)
54
+
55
+ for i, (col, label) in enumerate(zip(cols, steps), 1):
56
+ with col:
57
+ if i < current_step:
58
+ # Done
59
+ st.markdown(
60
+ f"<div style='text-align:center; color:#10b981; font-size:0.75rem; font-weight:700'>βœ“ {label}</div>",
61
+ unsafe_allow_html=True,
62
+ )
63
+ elif i == current_step:
64
+ # Active
65
+ st.markdown(
66
+ f"<div style='text-align:center; color:#8b5cf6; font-size:0.75rem; font-weight:700'>β—‰ {label}</div>",
67
+ unsafe_allow_html=True,
68
+ )
69
+ else:
70
+ # Pending
71
+ st.markdown(
72
+ f"<div style='text-align:center; color:#4a4a6a; font-size:0.75rem;'>β—‹ {label}</div>",
73
+ unsafe_allow_html=True,
74
+ )
75
+
76
+ st.divider()
77
+
78
+
79
+ def render_trust_badges():
80
+ """Trust badges menggunakan st.columns β€” tidak ada HTML."""
81
+ col1, col2, col3, col4 = st.columns(4)
82
+ badges = [
83
+ ("πŸ”’", "Stateless"),
84
+ ("πŸ—‘οΈ", "Tidak disimpan"),
85
+ ("⚑", "Gemini AI"),
86
+ ("🌿", "Human+ Bali"),
87
+ ]
88
+ for col, (icon, text) in zip([col1, col2, col3, col4], badges):
89
+ with col:
90
+ st.caption(f"{icon} {text}")
91
+ st.write("")
92
+
93
+
94
+ def render_section_label(text: str, icon: str = ""):
95
+ """
96
+ Section label menggunakan st.markdown dengan minimal styling β€”
97
+ hanya border-left yang tidak ada padanan native.
98
+ """
99
+ prefix = f"{icon} " if icon else ""
100
+ st.markdown(
101
+ f"<p style='font-size:0.7rem; font-weight:700; letter-spacing:0.14em; "
102
+ f"color:#8b8ba7; border-left:2px solid #8b5cf6; padding-left:8px; "
103
+ f"margin:20px 0 10px;'>{prefix}{text}</p>",
104
+ unsafe_allow_html=True,
105
+ )
106
+
107
+
108
+ # ════════════════════════════════════════════════
109
+ # LOADING STATES
110
+ # ════════════════════════════════════════════════
111
+
112
+ ANALYSIS_STEPS = [
113
+ "Membaca dokumen PDF",
114
+ "Mengidentifikasi biomarker",
115
+ "Mencocokkan dengan range Human+",
116
+ "Menyusun temuan utama",
117
+ ]
118
+
119
+ PROTOCOL_STEPS = [
120
+ "Mengambil knowledge base Human+",
121
+ "Menyesuaikan dengan hasil lab Anda",
122
+ "Menyusun rekomendasi IV drip",
123
+ "Finalisasi protocol personal",
124
+ ]
125
+
126
+
127
+ def render_loading_steps(steps: list[str], current_index: int):
128
+ """
129
+ Loading steps menggunakan st.markdown per baris β€” lebih stabil
130
+ dari satu blok HTML besar.
131
+ """
132
+ for i, step in enumerate(steps):
133
+ if i < current_index:
134
+ icon, color = "βœ“", "#10b981"
135
+ elif i == current_index:
136
+ icon, color = "β—‰", "#8b5cf6"
137
+ else:
138
+ icon, color = "β—‹", "#4a4a6a"
139
+
140
+ st.markdown(
141
+ f"<p style='color:{color}; font-size:0.85rem; margin:4px 0;'>"
142
+ f"{icon} &nbsp; {step}</p>",
143
+ unsafe_allow_html=True,
144
+ )
145
+
146
+
147
+ def render_progress_bar(value: float, label: str = ""):
148
+ """Native st.progress β€” tidak butuh HTML sama sekali."""
149
+ if label:
150
+ st.caption(label)
151
+ st.progress(value)
152
+
153
+
154
+ # ════════════════════════════════════════════════
155
+ # BIOMARKER COMPONENTS
156
+ # ════════════════════════════════════════════════
157
+
158
+ StatusType = Literal["optimal", "warning", "danger", "neutral"]
159
+
160
+ _STATUS_EMOJI = {
161
+ "optimal": "🟒",
162
+ "warning": "🟑",
163
+ "danger": "πŸ”΄",
164
+ "neutral": "βšͺ",
165
+ }
166
+
167
+ _STATUS_COLOR = {
168
+ "optimal": "#10b981",
169
+ "warning": "#f59e0b",
170
+ "danger": "#ef4444",
171
+ "neutral": "#6b7280",
172
+ }
173
+
174
+
175
+ def render_biomarker_row(
176
+ name: str,
177
+ value: str,
178
+ unit: str,
179
+ status: StatusType,
180
+ reference: str = "",
181
+ ):
182
+ """
183
+ Biomarker row menggunakan st.columns + st.metric.
184
+ Jauh lebih stabil dari HTML div, metric punya built-in styling.
185
+ Border kiri status tetap pakai satu baris HTML minimal.
186
+ """
187
+ color = _STATUS_COLOR.get(status, "#6b7280")
188
+ emoji = _STATUS_EMOJI.get(status, "βšͺ")
189
+
190
+ # Border kiri berwarna β€” satu-satunya HTML di sini
191
+ st.markdown(
192
+ f"<div style='border-left:3px solid {color}; padding-left:10px; margin:2px 0 0;'>",
193
+ unsafe_allow_html=True,
194
+ )
195
+
196
+ col_name, col_val = st.columns([3, 1])
197
+ with col_name:
198
+ st.markdown(f"**{emoji} {name}**")
199
+ if reference:
200
+ st.caption(f"Target Human+: {reference}")
201
+ with col_val:
202
+ st.metric(
203
+ label=unit,
204
+ value=value,
205
+ label_visibility="visible",
206
+ )
207
+
208
+ st.markdown("</div>", unsafe_allow_html=True)
209
+ st.write("")
210
+
211
+
212
+ def render_biomarker_summary_banner(optimal: int, warning: int, danger: int):
213
+ """
214
+ Summary count banner menggunakan st.columns + st.metric native.
215
+ Tidak ada HTML sama sekali.
216
+ """
217
+ total = optimal + warning + danger
218
+ st.caption(f"πŸ”¬ RINGKASAN BIOMARKER β€” {total} MARKER DIANALISIS")
219
+
220
+ col1, col2, col3 = st.columns(3)
221
+ with col1:
222
+ st.metric(label="πŸ”΄ Perlu Perhatian", value=str(danger))
223
+ with col2:
224
+ st.metric(label="🟑 Sub-Optimal", value=str(warning))
225
+ with col3:
226
+ st.metric(label="🟒 Optimal", value=str(optimal))
227
+
228
+ st.write("")
229
+
230
+
231
+ def render_biomarker_chip(name: str, status: StatusType):
232
+ """Inline chip menggunakan caption + emoji β€” tidak butuh HTML."""
233
+ emoji = _STATUS_EMOJI.get(status, "βšͺ")
234
+ st.caption(f"{emoji} {name}")
235
+
236
+
237
+ # ════════════════════════════════════════════════
238
+ # PROTOCOL COMPONENTS
239
+ # ════════════════════════════════════════════════
240
+
241
+ def render_protocol_section(
242
+ section_key: str,
243
+ content: str,
244
+ custom_icon: str = "",
245
+ custom_title: str = "",
246
+ accent_color: str = "",
247
+ ):
248
+ """
249
+ Protocol section menggunakan st.expander native sebagai container,
250
+ dengan header yang jelas. Content di-render sebagai st.markdown biasa.
251
+ """
252
+ cfg = PROTOCOL_SECTIONS.get(section_key, {
253
+ "icon": custom_icon or "πŸ“‹",
254
+ "title": custom_title or "Section",
255
+ "color": accent_color or COLORS["accent_violet"],
256
+ })
257
+
258
+ icon = custom_icon or cfg["icon"]
259
+ title = custom_title or cfg["title"]
260
+
261
+ with st.expander(f"{icon} {title}", expanded=True):
262
+ if content and content.strip():
263
+ st.markdown(content)
264
+ else:
265
+ st.caption("Tidak ada data untuk section ini.")
266
+
267
+
268
+ # ════════════════════════════════════════════════
269
+ # ERROR & EMPTY STATES
270
+ # ════════════════════════════════════════════════
271
+
272
+ def render_error_state(
273
+ title: str = "Terjadi Kesalahan",
274
+ message: str = "Silakan coba lagi.",
275
+ retry_label: str = "Coba Lagi",
276
+ show_contact: bool = False,
277
+ ):
278
+ """Error state menggunakan st.error native β€” selalu render dengan benar."""
279
+ st.error(f"**{title}**\n\n{message}", icon="⚠️")
280
+ if show_contact:
281
+ st.caption("Butuh bantuan? Hubungi [@bennywhitethatsright](https://instagram.com/bennywhitethatsright)")
282
+
283
+
284
+ def render_empty_state(
285
+ emoji: str = "🧬",
286
+ title: str = "Belum ada data",
287
+ message: str = "",
288
+ ):
289
+ """Empty state menggunakan st.info native."""
290
+ st.info(f"{emoji} **{title}**\n\n{message}" if message else f"{emoji} **{title}**")
291
+
292
+
293
+ # ═════════════════════════���══════════════════════
294
+ # FOOTER
295
+ # ════════════════════════════════════════════════
296
+
297
+ def render_footer():
298
+ """Footer menggunakan st.divider + st.caption native."""
299
+ st.divider()
300
+ st.caption(
301
+ "**FROM INJURED β†’ OPTIMIZED** \n"
302
+ "Human+ Bali Β· Didirikan oleh Benjamin White Β· "
303
+ "[@bennywhitethatsright](https://instagram.com/bennywhitethatsright) \n"
304
+ "*Data Anda tidak disimpan. Aplikasi 100% stateless. "
305
+ "Bukan pengganti konsultasi medis.*"
306
+ )
307
+
308
+
309
+ # ════════════════════════════════════════════════
310
+ # UTILITY CARDS
311
+ # ════════════════════════════════════════════════
312
+
313
+ def render_info_card(title: str, body: str, icon: str = "ℹ️"):
314
+ """
315
+ Info card menggunakan st.info β€” native, selalu render.
316
+ Body sebaiknya plain text, bukan HTML.
317
+ """
318
+ # Strip HTML tags sederhana dari body sebelum render
319
+ import re
320
+ clean_body = re.sub(r"<[^>]+>", "", body).strip()
321
+ st.info(f"{icon} **{title}**\n\n{clean_body}")
322
+
323
+
324
+ def render_success_banner(title: str, subtitle: str = ""):
325
+ """Success banner menggunakan st.success native."""
326
+ text = f"**{title}**"
327
+ if subtitle:
328
+ text += f"\n\n{subtitle}"
329
+ st.success(text, icon="βœ…")
330
+
331
+
332
+ def render_highlight_quote(text: str):
333
+ """
334
+ Motivational quote β€” satu-satunya komponen yang tetap pakai
335
+ HTML minimal karena border-left gradient tidak ada di native Streamlit.
336
+ """
337
+ st.markdown(
338
+ f"<blockquote style='"
339
+ f"border-left:3px solid #8b5cf6; padding:12px 16px; margin:16px 0; "
340
+ f"background:rgba(109,40,217,0.06); border-radius:0 8px 8px 0;'>"
341
+ f"<em style='color:#c4c4e0;'>{text}</em><br>"
342
+ f"<small style='color:#4a4a6a;'>β€” Benjamin White, Founder Human+ Bali</small>"
343
+ f"</blockquote>",
344
+ unsafe_allow_html=True,
345
+ )
src/ui/styles.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Human+ Custom CSS Injection
3
+ Prinsip: override hanya yang perlu, jangan ganggu layout internal Streamlit.
4
+ """
5
+
6
+ import streamlit as st
7
+ from ui.theme import COLORS, FONTS
8
+
9
+
10
+ def inject_custom_css():
11
+ st.markdown(_build_css(), unsafe_allow_html=True)
12
+
13
+
14
+ def _build_css() -> str:
15
+ c = COLORS
16
+ f = FONTS
17
+
18
+ return f"""
19
+ <link rel="preconnect" href="https://fonts.googleapis.com">
20
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
21
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
22
+
23
+ <style>
24
+
25
+ /* ════════════════════════════════════════════════
26
+ CSS VARIABLES
27
+ ════════════════════════════════════════════════ */
28
+ :root {{
29
+ --bg-deep: {c['bg_deep']};
30
+ --bg-surface: {c['bg_surface']};
31
+ --bg-elevated: {c['bg_elevated']};
32
+ --bg-line: {c['bg_line']};
33
+ --grad-from: {c['grad_from']};
34
+ --grad-to: {c['grad_to']};
35
+ --text-primary: {c['text_primary']};
36
+ --text-secondary:{c['text_secondary']};
37
+ --text-tertiary: {c['text_tertiary']};
38
+ --optimal: {c['optimal']};
39
+ --warning: {c['warning']};
40
+ --danger: {c['danger']};
41
+ --accent-violet: {c['accent_violet']};
42
+ --accent-cyan: {c['accent_cyan']};
43
+ --font-body: {f['body']};
44
+ --font-display: {f['display']};
45
+ --font-mono: {f['mono']};
46
+ }}
47
+
48
+ /* ════════════════════════════════════════════════
49
+ PAGE BACKGROUND
50
+ ════════════════════════════════════════════════ */
51
+ html, body, [data-testid="stApp"], .stApp {{
52
+ background-color: var(--bg-deep) !important;
53
+ }}
54
+
55
+ .main .block-container {{
56
+ padding: 0 16px 80px !important;
57
+ max-width: 680px !important;
58
+ margin: 0 auto !important;
59
+ }}
60
+
61
+ /* ════════════════════════════════════════════════
62
+ HIDE STREAMLIT CHROME
63
+ ════════════════════════════════════════════════ */
64
+ #MainMenu, footer, header {{ visibility: hidden; height: 0; }}
65
+ [data-testid="stToolbar"],
66
+ [data-testid="stSidebarNav"] {{ display: none; }}
67
+
68
+ /* ════════════════════════════════════════════════
69
+ TYPOGRAPHY
70
+ SCOPE KETAT β€” jangan override elemen interaktif
71
+ ════════════════════════════════════════════════ */
72
+
73
+ /* Body font hanya untuk konten markdown */
74
+ [data-testid="stMarkdownContainer"] {{
75
+ font-family: var(--font-body) !important;
76
+ }}
77
+
78
+ [data-testid="stMarkdownContainer"] p,
79
+ [data-testid="stMarkdownContainer"] li {{
80
+ color: var(--text-secondary) !important;
81
+ line-height: 1.65 !important;
82
+ font-size: 0.9rem;
83
+ }}
84
+
85
+ /* Headings dalam markdown */
86
+ [data-testid="stMarkdownContainer"] h1,
87
+ [data-testid="stMarkdownContainer"] h2,
88
+ [data-testid="stMarkdownContainer"] h3 {{
89
+ font-family: var(--font-display) !important;
90
+ color: var(--text-primary) !important;
91
+ line-height: 1.25 !important;
92
+ margin-top: 1.2em;
93
+ margin-bottom: 0.4em;
94
+ }}
95
+
96
+ /* Code */
97
+ [data-testid="stMarkdownContainer"] code {{
98
+ font-family: var(--font-mono) !important;
99
+ background: var(--bg-elevated) !important;
100
+ border: 1px solid var(--bg-line) !important;
101
+ border-radius: 4px !important;
102
+ color: var(--accent-cyan) !important;
103
+ padding: 1px 5px;
104
+ font-size: 0.82em;
105
+ }}
106
+
107
+ /* Caption */
108
+ [data-testid="stCaptionContainer"] {{
109
+ color: var(--text-tertiary) !important;
110
+ font-size: 0.78rem !important;
111
+ }}
112
+
113
+ /* ════════════════════════════════════════════════
114
+ BUTTONS
115
+ FIX: box-sizing + scoped selector
116
+ ════════════════════════════════════════════════ */
117
+ [data-testid="stButton"] > button {{
118
+ box-sizing: border-box !important;
119
+ font-family: var(--font-body) !important;
120
+ font-weight: 600 !important;
121
+ font-size: 0.88rem !important;
122
+ border-radius: 9999px !important;
123
+ border: none !important;
124
+ padding: 10px 24px !important;
125
+ min-height: 44px !important;
126
+ width: 100% !important;
127
+ cursor: pointer !important;
128
+ white-space: normal !important;
129
+ overflow: visible !important;
130
+ word-break: break-word !important;
131
+ line-height: 1.4 !important;
132
+ transition: opacity 0.15s, transform 0.15s, box-shadow 0.15s !important;
133
+ /* Default: gradient primary */
134
+ background: linear-gradient(135deg, var(--grad-from), var(--grad-to)) !important;
135
+ color: #fff !important;
136
+ box-shadow: 0 4px 18px rgba(109,40,217,0.3) !important;
137
+ }}
138
+
139
+ [data-testid="stButton"] > button:hover {{
140
+ opacity: 0.9 !important;
141
+ transform: translateY(-1px) !important;
142
+ box-shadow: 0 6px 24px rgba(109,40,217,0.45) !important;
143
+ }}
144
+
145
+ [data-testid="stButton"] > button:active {{
146
+ transform: translateY(0) !important;
147
+ opacity: 1 !important;
148
+ }}
149
+
150
+ /* Secondary button β€” override gradient */
151
+ [data-testid="stButton"] > button[kind="secondary"] {{
152
+ background: transparent !important;
153
+ border: 1px solid var(--bg-line) !important;
154
+ color: var(--text-secondary) !important;
155
+ box-shadow: none !important;
156
+ }}
157
+
158
+ [data-testid="stButton"] > button[kind="secondary"]:hover {{
159
+ border-color: var(--accent-violet) !important;
160
+ color: var(--text-primary) !important;
161
+ box-shadow: none !important;
162
+ transform: none !important;
163
+ opacity: 1 !important;
164
+ }}
165
+
166
+ /* Download button */
167
+ [data-testid="stDownloadButton"] > button {{
168
+ box-sizing: border-box !important;
169
+ background: var(--bg-elevated) !important;
170
+ border: 1px solid var(--bg-line) !important;
171
+ color: var(--text-primary) !important;
172
+ border-radius: 9999px !important;
173
+ font-family: var(--font-body) !important;
174
+ font-weight: 600 !important;
175
+ font-size: 0.85rem !important;
176
+ min-height: 44px !important;
177
+ width: 100% !important;
178
+ padding: 10px 20px !important;
179
+ box-shadow: none !important;
180
+ transition: border-color 0.15s, box-shadow 0.15s !important;
181
+ white-space: normal !important;
182
+ overflow: visible !important;
183
+ word-break: break-word !important;
184
+ line-height: 1.4 !important;
185
+ }}
186
+
187
+ [data-testid="stDownloadButton"] > button:hover {{
188
+ border-color: var(--accent-violet) !important;
189
+ box-shadow: 0 0 16px rgba(109,40,217,0.2) !important;
190
+ transform: none !important;
191
+ opacity: 1 !important;
192
+ }}
193
+
194
+ /* Link button */
195
+ [data-testid="stLinkButton"] > a {{
196
+ box-sizing: border-box !important;
197
+ display: block !important;
198
+ text-align: center !important;
199
+ border-radius: 9999px !important;
200
+ background: linear-gradient(135deg, var(--grad-from), var(--grad-to)) !important;
201
+ color: #fff !important;
202
+ font-family: var(--font-body) !important;
203
+ font-weight: 600 !important;
204
+ font-size: 0.88rem !important;
205
+ padding: 10px 24px !important;
206
+ min-height: 44px !important;
207
+ line-height: 1.4 !important;
208
+ text-decoration: none !important;
209
+ border: none !important;
210
+ box-shadow: 0 4px 18px rgba(109,40,217,0.3) !important;
211
+ transition: opacity 0.15s !important;
212
+ }}
213
+
214
+ [data-testid="stLinkButton"] > a:hover {{
215
+ opacity: 0.88 !important;
216
+ }}
217
+
218
+ /* ════════════════════════════════════════════════
219
+ FILE UPLOADER
220
+ FIX: jangan override internal layout β€” hanya warna border & background
221
+ ════════════════════════════════════════════════ */
222
+ [data-testid="stFileUploader"] {{
223
+ background: var(--bg-surface) !important;
224
+ border-radius: 12px !important;
225
+ }}
226
+
227
+ /* Border hanya pada dropzone, bukan seluruh widget */
228
+ [data-testid="stFileUploaderDropzone"] {{
229
+ background: var(--bg-elevated) !important;
230
+ border: 1.5px dashed var(--bg-line) !important;
231
+ border-radius: 10px !important;
232
+ transition: border-color 0.2s !important;
233
+ }}
234
+
235
+ [data-testid="stFileUploaderDropzone"]:hover {{
236
+ border-color: var(--accent-violet) !important;
237
+ }}
238
+
239
+ /* Label di atas dropzone */
240
+ [data-testid="stFileUploader"] > label {{
241
+ color: var(--text-secondary) !important;
242
+ font-family: var(--font-body) !important;
243
+ font-size: 0.88rem !important;
244
+ margin-bottom: 6px !important;
245
+ display: block !important;
246
+ }}
247
+
248
+ /* Teks instruksi dalam dropzone β€” jangan ubah layout, hanya warna */
249
+ [data-testid="stFileUploaderDropzoneInstructions"] > div > span {{
250
+ color: var(--text-tertiary) !important;
251
+ font-family: var(--font-body) !important;
252
+ font-size: 0.82rem !important;
253
+ }}
254
+
255
+ [data-testid="stFileUploaderDropzoneInstructions"] > div > small {{
256
+ color: var(--text-tertiary) !important;
257
+ font-size: 0.72rem !important;
258
+ }}
259
+
260
+ /* Browse button dalam uploader β€” scope ketat */
261
+ [data-testid="stFileUploaderDropzone"] button {{
262
+ background: var(--bg-elevated) !important;
263
+ border: 1px solid var(--accent-violet) !important;
264
+ color: var(--accent-violet) !important;
265
+ border-radius: 6px !important;
266
+ font-family: var(--font-body) !important;
267
+ font-size: 0.8rem !important;
268
+ padding: 5px 14px !important;
269
+ min-height: unset !important;
270
+ width: auto !important;
271
+ box-shadow: none !important;
272
+ font-weight: 500 !important;
273
+ }}
274
+
275
+ /* Cegah pseudo-element Streamlit render teks "Upload" ganda */
276
+ [data-testid="stFileUploaderDropzone"] button::before,
277
+ [data-testid="stFileUploaderDropzone"] button::after {{
278
+ display: none !important;
279
+ content: none !important;
280
+ }}
281
+
282
+ [data-testid="stFileUploaderDropzone"] button:hover {{
283
+ background: rgba(109,40,217,0.15) !important;
284
+ transform: none !important;
285
+ opacity: 1 !important;
286
+ }}
287
+
288
+ /* ════════════════════════════════════════════════
289
+ EXPANDER
290
+ FIX: summary layout agar tidak overlap
291
+ ════════════════════════════════════════════════ */
292
+ [data-testid="stExpander"] {{
293
+ background: var(--bg-surface) !important;
294
+ border: 1px solid var(--bg-line) !important;
295
+ border-radius: 12px !important;
296
+ overflow: visible !important;
297
+ margin-bottom: 10px !important;
298
+ }}
299
+
300
+ /* summary adalah elemen native <summary> β€” gunakan flex untuk align */
301
+ [data-testid="stExpander"] summary {{
302
+ display: flex !important;
303
+ align-items: center !important;
304
+ gap: 8px !important;
305
+ padding: 14px 16px !important;
306
+ background: var(--bg-surface) !important;
307
+ cursor: pointer !important;
308
+ list-style: none !important; /* hapus default marker */
309
+ min-height: 48px !important;
310
+ box-sizing: border-box !important;
311
+ }}
312
+
313
+ /* Teks judul expander β€” target span teks saja, bukan SVG */
314
+ [data-testid="stExpander"] summary > p {{
315
+ font-family: var(--font-body) !important;
316
+ font-weight: 600 !important;
317
+ font-size: 0.92rem !important;
318
+ color: var(--text-primary) !important;
319
+ line-height: 1.4 !important;
320
+ margin: 0 !important;
321
+ padding: 0 !important;
322
+ white-space: normal !important;
323
+ overflow: visible !important;
324
+ word-break: break-word !important;
325
+ flex: 1 !important;
326
+ min-width: 0 !important;
327
+ }}
328
+
329
+ /* Span dalam summary: hanya styling teks, TIDAK flex:1 agar tidak ganggu SVG */
330
+ [data-testid="stExpander"] summary span {{
331
+ font-family: var(--font-body) !important;
332
+ font-weight: 600 !important;
333
+ font-size: 0.92rem !important;
334
+ color: var(--text-primary) !important;
335
+ line-height: 1.4 !important;
336
+ white-space: normal !important;
337
+ overflow: visible !important;
338
+ word-break: break-word !important;
339
+ /* TIDAK ada flex: 1 di sini β€” SVG arrow juga <span>, akan berebut space */
340
+ }}
341
+
342
+ /* Arrow SVG β€” flex-shrink:0 agar tidak terkompresi */
343
+ [data-testid="stExpander"] summary svg {{
344
+ flex-shrink: 0 !important;
345
+ min-width: 16px !important;
346
+ color: var(--text-tertiary) !important;
347
+ }}
348
+
349
+ [data-testid="stExpander"] summary:hover {{
350
+ background: var(--bg-elevated) !important;
351
+ }}
352
+
353
+ /* Body expander */
354
+ [data-testid="stExpander"] > div[data-testid="stExpanderDetails"] {{
355
+ background: var(--bg-elevated) !important;
356
+ border-top: 1px solid var(--bg-line) !important;
357
+ padding: 16px !important;
358
+ }}
359
+
360
+ /* ════════════════════════════════════════════════
361
+ METRIC
362
+ ════════════════════════════════════════════════ */
363
+ [data-testid="stMetric"] {{
364
+ background: var(--bg-elevated) !important;
365
+ border: 1px solid var(--bg-line) !important;
366
+ border-radius: 10px !important;
367
+ padding: 10px 12px !important;
368
+ }}
369
+
370
+ [data-testid="stMetricLabel"] > div {{
371
+ color: var(--text-tertiary) !important;
372
+ font-size: 0.72rem !important;
373
+ font-family: var(--font-body) !important;
374
+ font-weight: 600 !important;
375
+ letter-spacing: 0.04em !important;
376
+ }}
377
+
378
+ [data-testid="stMetricValue"] > div {{
379
+ color: var(--text-primary) !important;
380
+ font-family: var(--font-mono) !important;
381
+ font-size: 1.3rem !important;
382
+ font-weight: 700 !important;
383
+ }}
384
+
385
+ /* ════════════════════════════════════════════════
386
+ ALERTS (st.success, st.error, st.info, st.warning)
387
+ ════════════════════════════════════════════════ */
388
+ [data-testid="stAlert"] {{
389
+ border-radius: 10px !important;
390
+ font-family: var(--font-body) !important;
391
+ font-size: 0.88rem !important;
392
+ border-width: 1px !important;
393
+ border-style: solid !important;
394
+ }}
395
+
396
+ /* Scope teks alert agar tidak dioverride oleh global p/span */
397
+ [data-testid="stAlert"] p,
398
+ [data-testid="stAlert"] span {{
399
+ font-family: var(--font-body) !important;
400
+ line-height: 1.55 !important;
401
+ }}
402
+
403
+ /* ════════════════════════════════════════════════
404
+ PROGRESS BAR
405
+ ════════════════════════════════════════════════ */
406
+ [data-testid="stProgressBar"] > div {{
407
+ background: var(--bg-elevated) !important;
408
+ border-radius: 9999px !important;
409
+ height: 6px !important;
410
+ }}
411
+
412
+ [data-testid="stProgressBar"] > div > div {{
413
+ background: linear-gradient(90deg, var(--grad-from), var(--grad-to)) !important;
414
+ border-radius: 9999px !important;
415
+ }}
416
+
417
+ /* ════════════════════════════════════════════════
418
+ DIVIDER
419
+ ════════════════════════════════════════════════ */
420
+ hr {{
421
+ border: none !important;
422
+ border-top: 1px solid var(--bg-line) !important;
423
+ margin: 20px 0 !important;
424
+ }}
425
+
426
+ /* ════════════════════════════════════════════════
427
+ COLUMNS β€” pastikan tidak ada overflow
428
+ ════════════════════════════════════════════════ */
429
+ [data-testid="stHorizontalBlock"] {{
430
+ gap: 8px !important;
431
+ align-items: stretch !important;
432
+ }}
433
+
434
+ [data-testid="stHorizontalBlock"] > div {{
435
+ min-width: 0 !important;
436
+ overflow: hidden !important;
437
+ }}
438
+
439
+ /* Expander di dalam kolom tidak boleh terpotong */
440
+ [data-testid="stHorizontalBlock"] > div:has([data-testid="stExpander"]) {{
441
+ overflow: visible !important;
442
+ }}
443
+
444
+ /* Step indicator teks β€” cegah meluber keluar kolom */
445
+ [data-testid="stHorizontalBlock"] > div > div[data-testid="stMarkdownContainer"] {{
446
+ overflow: hidden !important;
447
+ max-width: 100% !important;
448
+ }}
449
+
450
+ /* ════════════════════════════════════════════════
451
+ SCROLLBAR
452
+ ════════════════════════════════════════════════ */
453
+ ::-webkit-scrollbar {{ width: 4px; height: 4px; }}
454
+ ::-webkit-scrollbar-track {{ background: var(--bg-deep); }}
455
+ ::-webkit-scrollbar-thumb {{
456
+ background: var(--bg-line);
457
+ border-radius: 9999px;
458
+ }}
459
+ ::-webkit-scrollbar-thumb:hover {{ background: var(--accent-violet); }}
460
+
461
+ /* ════════════════════════════════════════════════
462
+ MOBILE RESPONSIVE
463
+ ════════════════════════════════════════════════ */
464
+ @media (max-width: 480px) {{
465
+ .main .block-container {{
466
+ padding: 0 10px 80px !important;
467
+ }}
468
+
469
+ [data-testid="stButton"] > button,
470
+ [data-testid="stDownloadButton"] > button {{
471
+ font-size: 0.85rem !important;
472
+ padding: 10px 16px !important;
473
+ min-height: 46px !important;
474
+ }}
475
+
476
+ [data-testid="stExpander"] summary {{
477
+ padding: 12px 12px !important;
478
+ }}
479
+
480
+ [data-testid="stExpander"] summary > p,
481
+ [data-testid="stExpander"] summary span {{
482
+ font-size: 0.85rem !important;
483
+ white-space: normal !important;
484
+ overflow: visible !important;
485
+ }}
486
+
487
+ [data-testid="stMetricValue"] > div {{
488
+ font-size: 1.1rem !important;
489
+ }}
490
+ }}
491
+
492
+ /* ════════════════════════════════════════════════
493
+ ATMOSPHERIC ORBS (background depth)
494
+ ════════════════════════════════════════════════ */
495
+ .hp-orbs {{
496
+ position: fixed; inset: 0;
497
+ pointer-events: none; z-index: 0;
498
+ overflow: hidden;
499
+ }}
500
+ .hp-orb {{
501
+ position: absolute; border-radius: 50%;
502
+ filter: blur(90px); opacity: 0.06;
503
+ }}
504
+ .hp-orb-1 {{
505
+ width: 450px; height: 450px;
506
+ background: {c['grad_from']};
507
+ top: -120px; right: -120px;
508
+ }}
509
+ .hp-orb-2 {{
510
+ width: 320px; height: 320px;
511
+ background: {c['grad_to']};
512
+ bottom: 80px; left: -100px;
513
+ }}
514
+
515
+ </style>
516
+
517
+ <!-- Background orbs -->
518
+ <div class="hp-orbs">
519
+ <div class="hp-orb hp-orb-1"></div>
520
+ <div class="hp-orb hp-orb-2"></div>
521
+ </div>
522
+ """