FredinVázquez commited on
Commit
fced97d
·
1 Parent(s): a915481

Revert "Reapply "update UI""

Browse files

This reverts commit a915481fed41979c27155a28d783251a92db2eec.

Files changed (3) hide show
  1. app.py +0 -2
  2. src/ui/components.py +391 -115
  3. src/ui/theme.py +71 -116
app.py CHANGED
@@ -21,8 +21,6 @@ from src.ui.components import (
21
  VerdictBadge,
22
  )
23
  from src.ui.theme import CSS, theme
24
- from src.ui.components import DishOptions, IngredientChips, NutritionGrid, RecipeHero, StepCard, VerdictBadge
25
-
26
 
27
 
28
  # ---------------------------------------------------------------------------
 
21
  VerdictBadge,
22
  )
23
  from src.ui.theme import CSS, theme
 
 
24
 
25
 
26
  # ---------------------------------------------------------------------------
src/ui/components.py CHANGED
@@ -1,120 +1,396 @@
1
- class IngredientChips:
2
- @staticmethod
3
- def render(data: dict) -> str:
4
- have = data.get("have", [])
5
- if not have:
6
- return "<div class='chip-container'><span class='chip'>No ingredients yet. Upload a photo!</span></div>"
7
-
8
- chips_html = "".join([f"<span class='chip'>🥑 {item.capitalize()}</span>" for item in have])
9
- return f"<div class='chip-container'>{chips_html}</div>"
10
-
11
- class DishOptions:
12
- @staticmethod
13
- def render(data: dict) -> str:
14
- options = data.get("options", [])
15
- if not options:
16
- return "<div></div>"
17
-
18
- cards = ""
19
- for opt in options:
20
- # Assuming opt is a dict with 'name' and 'time'
21
- name = opt.get('name', 'Unknown Dish')
22
- time = opt.get('time', '30 min')
23
- cards += f"""
24
- <div class='dish-card'>
25
- <h4>{name}</h4>
26
- <span class='chip'>⏱️ {time}</span>
27
- </div>
28
- """
29
- return f"<div class='dish-grid'>{cards}</div>"
30
-
31
- class NutritionGrid:
32
- @staticmethod
33
- def render(data: dict) -> str:
34
- nut = data.get("nutrition", {})
35
- if not nut:
36
- return "<div></div>"
37
-
38
- return f"""
39
- <div class='glass-card'>
40
- <h3 style="margin-top:0; color:#2d3748;">Nutrition Quantity</h3>
41
- <div class='nutrition-row'>
42
- <div class='pill-badge'>
43
- <span class='pill-value'>{nut.get('calories', 0)}</span>
44
- <span class='pill-label'>Kcal</span>
45
- </div>
46
- <div class='pill-badge'>
47
- <span class='pill-value'>{nut.get('protein', 0)}</span>
48
- <span class='pill-label'>Protein (g)</span>
49
- </div>
50
- <div class='pill-badge'>
51
- <span class='pill-value'>{nut.get('carbs', 0)}</span>
52
- <span class='pill-label'>Carbs (g)</span>
53
- </div>
54
- <div class='pill-badge'>
55
- <span class='pill-value'>{nut.get('fat', 0)}</span>
56
- <span class='pill-label'>Fat (g)</span>
57
- </div>
58
- </div>
59
- </div>
60
- """
61
-
62
- class RecipeHero:
63
- @staticmethod
64
- def render(data: dict) -> str:
65
- if not data:
66
- return "<div class='glass-card' style='text-align:center; padding: 40px; color:#a0aec0;'>Pick a dish to get started</div>"
67
-
68
- title = data.get('name', 'Your Recipe')
69
- # Fallback to a placeholder if no FLUX image is generated yet
70
- img_url = data.get('image_url', 'https://images.unsplash.com/photo-1495195134817-a169d325145b?q=80&w=800&auto=format&fit=crop')
71
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  return f"""
73
- <div class='hero-container'>
74
- <img src="{img_url}" class="hero-image" alt="Recipe Image">
75
- <div class='hero-overlay'>
76
- <h2>{title}</h2>
77
- <p style="margin: 5px 0 0 0; font-size:16px;">⭐ 4.9 • Free-hands Cooking</p>
78
- </div>
79
- </div>
80
- """
81
-
82
- class StepCard:
83
- @staticmethod
84
- def render(data: dict) -> str:
85
- steps = data.get("steps", [])
86
- if not steps:
87
- return "<div></div>"
88
-
89
- steps_html = "<div class='glass-card'><h3 style='margin-top:0; color:#2d3748;'>Steps</h3><ul style='list-style-type:none; padding:0;'>"
90
- for i, step in enumerate(steps, 1):
91
- inst = step.get('instruction', '')
92
- steps_html += f"""
93
- <li style="margin-bottom: 16px; display: flex; gap: 12px; align-items: flex-start;">
94
- <span style="background: #2d3748; color: white; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-weight: bold;">{i}</span>
95
- <span style="color: #4a5568; line-height: 1.5; padding-top: 3px;">{inst}</span>
96
- </li>
97
- """
98
- steps_html += "</ul></div>"
99
- return steps_html
100
-
101
- class VerdictBadge:
102
- @staticmethod
103
- def render(data: dict) -> str:
104
- if not data:
105
- return "<div></div>"
106
- status = data.get("status", "wait") # go, wait, fix
107
- msg = data.get("message", "Keep going!")
108
 
109
- color_map = {"go": "#c6f6d5", "wait": "#feebc8", "fix": "#fed7d7"}
110
- text_map = {"go": "#22543d", "wait": "#7b341e", "fix": "#742a2a"}
 
 
 
 
 
 
 
 
 
111
 
112
- bg = color_map.get(status, "#edf2f7")
113
- tc = text_map.get(status, "#2d3748")
 
 
 
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  return f"""
116
- <div class='glass-card' style='background: {bg}; color: {tc}; text-align: center; border: none;'>
117
- <h3 style='margin:0 0 8px 0; color: {tc};'>Chef's Verdict</h3>
118
- <p style='margin:0; font-size: 18px; font-weight: 500;'>{msg}</p>
119
- </div>
120
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom HTML components — Off-Brand UI built on top of `gr.HTML`.
2
+ Each component subclasses ``gr.HTML`` and ships its own ``html_template`` and
3
+ ``css_template``. State is a Python dict; ``render(state)`` returns a complete
4
+ ``<style>...</style><div>...</div>`` blob ready to drop into the page. Updates
5
+ flow through normal Gradio event callbacks — call ``Component.render(...)`` in
6
+ your handler and yield the new HTML string.
7
+ Pattern adapted from "Gradio HTML components.md": templates substitute
8
+ ``${value.X}`` tokens against the state dict, every component bundles its own
9
+ CSS, and components are easy to compose because they're just `gr.HTML` under
10
+ the hood.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import html
15
+ import json
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any, ClassVar
19
+
20
+ import gradio as gr
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Base class: TemplatedHTML
25
+ # ---------------------------------------------------------------------------
26
+ TOKEN_RE = re.compile(r"\$\{([^}]+)\}")
27
+
28
+
29
+ def _resolve(expr: str, scope: dict[str, Any]) -> Any:
30
+ """Resolve ``value.steps[0].instruction``-style expressions against scope."""
31
+ parts = re.split(r"\.|\[|\]", expr)
32
+ parts = [p for p in parts if p != ""]
33
+ cur: Any = scope
34
+ for p in parts:
35
+ if isinstance(cur, dict) and p in cur:
36
+ cur = cur[p]
37
+ elif isinstance(cur, list) and p.isdigit():
38
+ cur = cur[int(p)]
39
+ elif hasattr(cur, p):
40
+ cur = getattr(cur, p)
41
+ else:
42
+ return ""
43
+ return cur
44
+
45
+
46
+ def substitute(template: str, state: dict[str, Any]) -> str:
47
+ scope = {"value": state}
48
+ def _sub(m: re.Match) -> str:
49
+ out = _resolve(m.group(1), scope)
50
+ if out is None:
51
+ return ""
52
+ return html.escape(str(out)) if not isinstance(out, str) else html.escape(out)
53
+ return TOKEN_RE.sub(_sub, template)
54
+
55
+
56
+ class TemplatedHTML(gr.HTML):
57
+ """A gr.HTML wrapper that re-renders a template against a state dict."""
58
+
59
+ html_template: ClassVar[str] = ""
60
+ css_template: ClassVar[str] = ""
61
+
62
+ def __init__(self, value: dict[str, Any] | None = None, **kwargs):
63
+ self._state: dict[str, Any] = value or {}
64
+ super().__init__(value=self.render(self._state), **kwargs)
65
+
66
+ @classmethod
67
+ def render(cls, state: dict[str, Any]) -> str:
68
+ body = cls._render_body(state)
69
+ return f"<style>{cls.css_template}</style>{body}"
70
+
71
+ @classmethod
72
+ def _render_body(cls, state: dict[str, Any]) -> str:
73
+ # Default: simple ${value.X} substitution against the state dict.
74
+ return substitute(cls.html_template, state)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # RecipeHero — title + final-dish image + summary line
79
+ # ---------------------------------------------------------------------------
80
+ class RecipeHero(TemplatedHTML):
81
+ css_template = """
82
+ .cwm-hero {
83
+ background: #fffbf0 !important;
84
+ border: 1px solid #d8c9ad;
85
+ border-radius: 16px;
86
+ padding: 32px;
87
+ display: grid;
88
+ grid-template-columns: 1fr 1fr;
89
+ gap: 28px;
90
+ box-shadow: 0 10px 28px rgba(107, 74, 42, 0.10);
91
+ }
92
+ .cwm-hero img {
93
+ width: 100%; height: 320px; object-fit: cover; border-radius: 12px;
94
+ background: #efe3c8;
95
+ }
96
+ .cwm-hero h1 {
97
+ font-family: 'Lora', serif; font-size: 38px; color: #6b4a2a !important;
98
+ margin: 0 0 8px;
99
+ }
100
+ .cwm-hero .meta {
101
+ color: #8a6a3a !important; font-size: 14px; letter-spacing: 0.04em;
102
+ text-transform: uppercase; margin-bottom: 18px;
103
+ }
104
+ .cwm-hero .visual {
105
+ font-family: 'Lora', serif; font-style: italic; color: #6b4a2a !important;
106
+ font-size: 17px; line-height: 1.55;
107
+ }
108
+ @media (max-width: 720px) { .cwm-hero { grid-template-columns: 1fr; } }
109
+ """
110
+
111
+ @classmethod
112
+ def _render_body(cls, state: dict[str, Any]) -> str:
113
+ name = html.escape(state.get("name") or "Pick a dish to get started")
114
+ cuisine = html.escape(state.get("cuisine") or "")
115
+ servings = state.get("servings") or 0
116
+ time = state.get("total_time_minutes") or 0
117
+ visual = html.escape(state.get("final_dish_visual") or "")
118
+ img_b64 = state.get("final_dish_image_b64") or ""
119
+ img_path = state.get("final_dish_image_path") or ""
120
+ if img_b64:
121
+ img_tag = f'<img src="data:image/png;base64,{img_b64}" alt="final dish"/>'
122
+ elif img_path:
123
+ img_tag = f'<img src="/file={html.escape(img_path)}" alt="final dish"/>'
124
+ else:
125
+ img_tag = '<div style="background:#efe3c8;border-radius:12px;height:320px;display:flex;align-items:center;justify-content:center;color:#8a6a3a;font-family:\'Lora\',serif;font-style:italic;">Image will appear here</div>'
126
  return f"""
127
+ <div class="cwm-hero">
128
+ <div>{img_tag}</div>
129
+ <div>
130
+ <div class="meta">{cuisine} · {servings} servings · {time} min</div>
131
+ <h1>{name}</h1>
132
+ <p class="visual">{visual}</p>
133
+ </div>
134
+ </div>
135
+ """
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # IngredientChips — wrap-around chips with "have / missing" tinting
140
+ # ---------------------------------------------------------------------------
141
+ class IngredientChips(TemplatedHTML):
142
+ css_template = """
143
+ .cwm-chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
+ /* Forzamos el color del texto y nos aseguramos de que no se herede un color claro */
146
+ .cwm-chips .cwm-chip {
147
+ background: #fbe2d2 !important;
148
+ color: #6b4a2a !important;
149
+ border: 1px solid #d8c9ad !important;
150
+ border-radius: 999px;
151
+ padding: 6px 14px;
152
+ font-size: 14px;
153
+ font-family: 'Inter', sans-serif;
154
+ display: inline-block;
155
+ }
156
 
157
+ /* Forzamos el color para los chips de ingredientes faltantes */
158
+ .cwm-chips .cwm-chip.missing {
159
+ background: #fbe2d2 !important;
160
+ border-color: #c47a52 !important;
161
+ }
162
 
163
+ .cwm-chips-empty {
164
+ color: #6b4a2a !important;
165
+ font-style: italic;
166
+ padding: 14px 0;
167
+ }
168
+ """
169
+
170
+ @classmethod
171
+ def _render_body(cls, state: dict[str, Any]) -> str:
172
+ have = state.get("have") or []
173
+ missing = state.get("missing") or []
174
+ if not have and not missing:
175
+ return '<div class="cwm-chips-empty">No ingredients yet — upload a fridge photo.</div>'
176
+ parts = ['<div class="cwm-chips">']
177
+ for ing in have:
178
+ parts.append(f'<span class="cwm-chip">{html.escape(str(ing))}</span>')
179
+ for ing in missing:
180
+ parts.append(f'<span class="cwm-chip missing">missing: {html.escape(str(ing))}</span>')
181
+ parts.append('</div>')
182
+ return "".join(parts)
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # DishOptions — three picker buttons (rendered as plain HTML; the actual
187
+ # selection is handled by a sibling Gradio Radio for state binding)
188
+ # ---------------------------------------------------------------------------
189
+ class DishOptions(TemplatedHTML):
190
+ css_template = """
191
+ .cwm-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
192
+ .cwm-options .cwm-option {
193
+ background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 12px;
194
+ padding: 18px; text-align: left;
195
+ }
196
+ .cwm-options .cwm-option h3 {
197
+ font-family: 'Lora', serif; font-size: 19px; color: #6b4a2a !important;
198
+ margin: 0 0 6px;
199
+ }
200
+ .cwm-options .cwm-option p { color: #7a5a35 !important; font-size: 14px; line-height: 1.45; margin: 0; }
201
+ @media (max-width: 720px) { .cwm-options { grid-template-columns: 1fr; } }
202
+ """
203
+
204
+ @classmethod
205
+ def _render_body(cls, state: dict[str, Any]) -> str:
206
+ opts = state.get("options") or []
207
+ if not opts:
208
+ return ""
209
+ cards = []
210
+ for o in opts[:3]:
211
+ name = html.escape(str(o.get("name", "")))
212
+ why = html.escape(str(o.get("why", "")))
213
+ cards.append(f'<div class="cwm-option"><h3>{name}</h3><p>{why}</p></div>')
214
+ return f'<div class="cwm-options">{"".join(cards)}</div>'
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # StepCard — one card per step (image + instruction + duration + tip)
219
+ # ---------------------------------------------------------------------------
220
+ class StepCard(TemplatedHTML):
221
+ css_template = """
222
+ .cwm-steps { display: flex; flex-direction: column; gap: 16px; }
223
+ .cwm-steps .cwm-step {
224
+ display: grid; grid-template-columns: 220px 1fr; gap: 22px;
225
+ background: #fffbf0 !important; border-left: 4px solid #a85c2a; border-radius: 10px;
226
+ padding: 18px 22px;
227
+ }
228
+ .cwm-steps .cwm-step img {
229
+ width: 220px; height: 160px; object-fit: cover; border-radius: 8px;
230
+ background: #efe3c8;
231
+ }
232
+ .cwm-steps .cwm-step .placeholder {
233
+ width: 220px; height: 160px; border-radius: 8px;
234
+ background: linear-gradient(135deg,#efe3c8,#dccaa3);
235
+ display:flex; align-items:center; justify-content:center;
236
+ color: #8a6a3a !important; font-family: 'Lora', serif; font-size: 14px;
237
+ }
238
+ .cwm-steps .cwm-step h3 {
239
+ font-family: 'Lora', serif; color: #6b4a2a !important; margin: 0 0 6px; font-size: 22px;
240
+ }
241
+ .cwm-steps .cwm-step p { font-size: 16px; line-height: 1.55; color: #4a3722 !important; margin: 0 0 8px; }
242
+ .cwm-steps .cwm-step .duration {
243
+ display: inline-block; background: #a85c2a !important; color: #fffbf0 !important;
244
+ border-radius: 999px; padding: 3px 10px; font-size: 12px; letter-spacing: 0.04em;
245
+ }
246
+ .cwm-steps .cwm-step .tip {
247
+ margin-top: 10px; padding: 10px 12px; background: #fff3d8 !important;
248
+ border-radius: 8px; font-size: 14px; color: #6b4a2a !important;
249
+ }
250
+ .cwm-step .tip::before { content: "💡 "; }
251
+ @media (max-width: 720px) { .cwm-step { grid-template-columns: 1fr; } .cwm-step img, .cwm-step .placeholder { width: 100%; } }
252
+ """
253
+
254
+ @classmethod
255
+ def _render_body(cls, state: dict[str, Any]) -> str:
256
+ steps = state.get("steps") or []
257
+ if not steps:
258
+ return '<div style="color:#b39870;font-style:italic;padding:14px 0;">No steps yet.</div>'
259
+ cards = []
260
+ for s in steps:
261
+ n = s.get("n", 0)
262
+ instr = html.escape(s.get("instruction", ""))
263
+ dur = html.escape(s.get("duration", ""))
264
+ tip = s.get("tip")
265
+ visual = html.escape(s.get("visual", ""))
266
+ img_b64 = s.get("image_b64") or ""
267
+ img_path = s.get("image_path") or ""
268
+ if img_b64:
269
+ img_block = f'<img src="data:image/png;base64,{img_b64}" alt="step {n}"/>'
270
+ elif img_path:
271
+ img_block = f'<img src="/file={html.escape(img_path)}" alt="step {n}"/>'
272
+ else:
273
+ img_block = f'<div class="placeholder">{visual[:80] if visual else f"Step {n}"}</div>'
274
+ tip_block = f'<div class="tip">{html.escape(tip)}</div>' if tip else ""
275
+ cards.append(f"""
276
+ <div class="cwm-step">
277
+ {img_block}
278
+ <div>
279
+ <h3>Step {n}</h3>
280
+ <p>{instr}</p>
281
+ <span class="duration">{dur}</span>
282
+ {tip_block}
283
+ </div>
284
+ </div>
285
+ """)
286
+ return f'<div class="cwm-steps">{"".join(cards)}</div>'
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # NutritionGrid — five macro cells
291
+ # ---------------------------------------------------------------------------
292
+ class NutritionGrid(TemplatedHTML):
293
+ css_template = """
294
+ .cwm-nutri-wrap { margin-top: 10px; }
295
+ .cwm-nutri-title {
296
+ font-family: 'Lora', serif; color: #6b4a2a !important; font-size: 22px; margin: 0 0 14px;
297
+ }
298
+ .cwm-nutri {
299
+ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px;
300
+ }
301
+ .cwm-nutri .cwm-nutri-cell {
302
+ background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 10px;
303
+ padding: 14px 10px; text-align: center;
304
+ }
305
+ .cwm-nutri .cwm-nutri-cell .v {
306
+ font-family: 'Lora', serif; font-size: 24px; font-weight: 700; color: #6b4a2a !important;
307
+ display: block;
308
+ }
309
+ .cwm-nutri .cwm-nutri-cell .l {
310
+ font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
311
+ color: #8a6a3a !important; margin-top: 4px;
312
+ }
313
+ @media (max-width: 720px) { .cwm-nutri { grid-template-columns: repeat(2, 1fr); } }
314
+ """
315
+
316
+ @classmethod
317
+ def _render_body(cls, state: dict[str, Any]) -> str:
318
+ nutri = state.get("nutrition") or {}
319
+ cells = [
320
+ (nutri.get("calories", 0), "kcal"),
321
+ (nutri.get("protein_g", 0), "protein"),
322
+ (nutri.get("carbs_g", 0), "carbs"),
323
+ (nutri.get("fat_g", 0), "fat"),
324
+ (nutri.get("fiber_g", 0), "fiber"),
325
+ ]
326
+ cell_html = "".join(
327
+ f'<div class="cwm-nutri-cell"><span class="v">{html.escape(str(v))}</span>'
328
+ f'<div class="l">{html.escape(label)}</div></div>'
329
+ for v, label in cells
330
+ )
331
+ return f"""
332
+ <div class="cwm-nutri-wrap">
333
+ <h3 class="cwm-nutri-title">Per serving</h3>
334
+ <div class="cwm-nutri">{cell_html}</div>
335
+ </div>
336
+ """
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # VerdictBadge — go / wait / fix indicator + tip
341
+ # ---------------------------------------------------------------------------
342
+ class VerdictBadge(TemplatedHTML):
343
+ css_template = """
344
+ .cwm-verdict {
345
+ display: flex; align-items: center; gap: 18px;
346
+ background: #fffbf0 !important; border-radius: 12px; padding: 18px 22px;
347
+ border: 1px solid #d8c9ad;
348
+ }
349
+ .cwm-verdict.go { border-left: 6px solid #4f8b4a; }
350
+ .cwm-verdict.wait { border-left: 6px solid #d4a23c; }
351
+ .cwm-verdict.fix { border-left: 6px solid #b94a3a; }
352
+ .cwm-verdict-pill {
353
+ font-family: 'Lora', serif; font-weight: 700; font-size: 16px;
354
+ text-transform: uppercase; letter-spacing: 0.08em;
355
+ padding: 8px 16px; border-radius: 999px; color: #fffbf0;
356
+ }
357
+ .cwm-verdict.go .cwm-verdict-pill { background: #4f8b4a; }
358
+ .cwm-verdict.wait .cwm-verdict-pill { background: #d4a23c; }
359
+ .cwm-verdict.fix .cwm-verdict-pill { background: #b94a3a; }
360
+ .cwm-verdict-text { font-size: 16px; color: #4a3722 !important; line-height: 1.5; }
361
+ .cwm-verdict-text small { color: #8a6a3a !important; display: block; margin-top: 4px; }
362
+ .cwm-verdict-empty {
363
+ color: #b39870; font-style: italic; padding: 14px 0;
364
+ }
365
+ """
366
+
367
+ @classmethod
368
+ def _render_body(cls, state: dict[str, Any]) -> str:
369
+ v = state.get("verdict")
370
+ if not v:
371
+ return '<div class="cwm-verdict-empty">Upload a progress photo to get a verdict.</div>'
372
+ verdict = state.get("verdict", "wait")
373
+ feedback = html.escape(state.get("feedback", ""))
374
+ tip = state.get("tip")
375
+ tip_html = f"<small>{html.escape(tip)}</small>" if tip else ""
376
  return f"""
377
+ <div class="cwm-verdict {html.escape(verdict)}">
378
+ <div class="cwm-verdict-pill">{html.escape(verdict)}</div>
379
+ <div class="cwm-verdict-text">{feedback}{tip_html}</div>
380
+ </div>
381
+ """
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # Convenience helpers — ergonomic state-to-HTML for callbacks
386
+ # ---------------------------------------------------------------------------
387
+ def recipe_to_state(recipe: dict | object) -> dict:
388
+ """Normalize a Recipe (Pydantic or dict) into the flat dict every
389
+ component reads. Pydantic models are dumped via ``model_dump`` if present."""
390
+ if hasattr(recipe, "model_dump"):
391
+ d = recipe.model_dump()
392
+ elif isinstance(recipe, dict):
393
+ d = recipe
394
+ else:
395
+ d = json.loads(json.dumps(recipe, default=lambda o: o.__dict__))
396
+ return d
src/ui/theme.py CHANGED
@@ -1,133 +1,88 @@
 
 
 
1
  import gradio as gr
2
 
3
- # A soft, modern theme overriding Gradio's default harsh borders
4
  theme = gr.themes.Soft(
5
- primary_hue="emerald",
6
- neutral_hue="slate",
7
- font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
8
- radius_size=gr.themes.sizes.radius_xxl,
9
- ).set(
10
- body_background_fill="#f7f9fc", # Light modern gray/blue background
11
- block_background_fill="#ffffff",
12
- block_border_width="0px",
13
- block_shadow="0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05)",
14
- button_primary_background_fill="*primary_500",
15
- button_primary_background_fill_hover="*primary_600",
16
  )
17
 
18
- # Custom CSS for your injected HTML components to match the mockups
19
  CSS = """
20
- /* Global Container Resets */
21
- .gradio-container {
22
- max-width: 1200px !important;
23
- margin: auto;
24
- }
25
 
26
- /* Card Styling (Inspired by the pastel app mockup) */
27
- .glass-card {
28
- background: #ffffff;
29
- border-radius: 24px;
30
- padding: 20px;
31
- box-shadow: 0 10px 25px rgba(0,0,0,0.03);
32
- margin-bottom: 16px;
33
- transition: transform 0.2s ease;
34
- }
35
 
36
- /* Nutrition Pills (Inspired by the green salad mockup) */
37
- .nutrition-row {
38
- display: flex;
39
- gap: 12px;
40
- justify-content: space-around;
41
- flex-wrap: wrap;
42
- margin-top: 10px;
43
- }
44
- .pill-badge {
45
- display: flex;
46
- flex-direction: column;
47
- align-items: center;
48
- justify-content: center;
49
- background: #e6f4ea; /* Light sage green */
50
- color: #1e4620; /* Dark green text */
51
- border-radius: 40px;
52
- padding: 16px 20px;
53
- min-width: 80px;
54
- box-shadow: 0 4px 10px rgba(0,0,0,0.02);
55
- }
56
- .pill-value {
57
- font-size: 24px;
58
- font-weight: 800;
59
- line-height: 1;
60
- margin-bottom: 4px;
61
- }
62
- .pill-label {
63
- font-size: 12px;
64
- font-weight: 600;
65
- text-transform: uppercase;
66
- letter-spacing: 0.5px;
67
  }
68
 
69
- /* Dish Proposal Cards */
70
- .dish-grid {
71
- display: grid;
72
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
73
- gap: 16px;
74
- margin-top: 10px;
75
- }
76
- .dish-card {
77
- background: linear-gradient(135deg, #f0fff4 0%, #e6fffa 100%);
78
- border-radius: 20px;
79
- padding: 16px;
80
- text-align: center;
81
- border: 1px solid #c6f6d5;
82
- }
83
- .dish-card h4 {
84
- margin: 0 0 8px 0;
85
- font-size: 18px;
86
- color: #2d3748;
87
  }
88
 
89
- /* Hero Image Header (Inspired by the spaghetti mockup) */
90
- .hero-container {
91
- position: relative;
92
- border-radius: 24px;
93
- overflow: hidden;
94
- margin-bottom: 24px;
95
- box-shadow: 0 10px 30px rgba(0,0,0,0.08);
96
- }
97
- .hero-image {
98
- width: 100%;
99
- height: 300px;
100
- object-fit: cover;
101
- display: block;
102
- }
103
- .hero-overlay {
104
- position: absolute;
105
- bottom: 0;
106
- left: 0;
107
- right: 0;
108
- background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
109
- padding: 30px 20px 20px;
110
- color: white;
111
  }
112
- .hero-overlay h2 {
113
- margin: 0;
114
- font-size: 32px;
115
- font-weight: 800;
116
- color: white;
 
 
 
117
  }
118
 
119
- /* Ingredient Chips */
120
- .chip-container {
121
- display: flex;
122
- flex-wrap: wrap;
123
- gap: 8px;
 
124
  }
125
- .chip {
126
- background: #edf2f7;
127
- color: #4a5568;
128
- padding: 6px 14px;
129
- border-radius: 20px;
130
- font-size: 14px;
131
- font-weight: 500;
132
  }
133
- """
 
1
+ """Gradio theme + global CSS for the recipe-card look."""
2
+ from __future__ import annotations
3
+
4
  import gradio as gr
5
 
 
6
  theme = gr.themes.Soft(
7
+ primary_hue="orange",
8
+ neutral_hue="stone",
9
+ font=[gr.themes.GoogleFont("Inter"), "sans-serif"],
10
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"],
 
 
 
 
 
 
 
11
  )
12
 
13
+
14
  CSS = """
15
+ @import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;700&display=swap');
 
 
 
 
16
 
17
+ /* ---------------------------------------------------------------------------
18
+ Force a warm light palette regardless of the browser/system dark mode.
19
+ We pin the parchment background, so we must also pin DARK text colours via
20
+ Gradio's CSS variables — otherwise dark-mode users get white text on the
21
+ light background and it disappears.
22
+ --------------------------------------------------------------------------- */
23
+ .gradio-container, .gradio-container.dark {
24
+ background: #f5ecd9 !important;
25
+ color-scheme: light !important;
26
 
27
+ --body-text-color: #4a3722;
28
+ --body-text-color-subdued: #7a5a35;
29
+ --block-title-text-color: #6b4a2a;
30
+ --block-label-text-color: #6b4a2a;
31
+ --block-info-text-color: #7a5a35;
32
+ --block-background-fill: #fffbf0;
33
+ --input-background-fill: #fffbf0;
34
+ --border-color-primary: #d8c9ad;
35
+ --color-accent-soft: #fbe2d2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
+ /* Blanket dark text for native Gradio text elements (covers dark mode) */
39
+ .gradio-container,
40
+ .gradio-container .prose,
41
+ .gradio-container label,
42
+ .gradio-container .gr-text,
43
+ .gradio-container span,
44
+ .gradio-container p,
45
+ .gradio-container .gr-check-radio label,
46
+ .gradio-container .wrap,
47
+ .gradio-container .gr-form,
48
+ .gradio-container .tab-nav button,
49
+ .gradio-container .gr-accordion,
50
+ .gradio-container input,
51
+ .gradio-container textarea {
52
+ color: #4a3722 !important;
 
 
 
53
  }
54
 
55
+ .gradio-container .prose h1,
56
+ .gradio-container .prose h2,
57
+ .gradio-container .prose h3 { font-family: 'Lora', serif !important; color: #6b4a2a !important; }
58
+
59
+ /* Tabs: dark labels, terracotta active */
60
+ .gradio-container .tab-nav button { color: #6b4a2a !important; }
61
+ .gradio-container .tab-nav button.selected {
62
+ color: #a85c2a !important; border-bottom-color: #a85c2a !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
+
65
+ /* Native blocks (inputs, radio, checkbox, number) on warm cards */
66
+ .gradio-container .block,
67
+ .gradio-container .form,
68
+ .gradio-container input[type="text"],
69
+ .gradio-container input[type="number"] {
70
+ background: #fffbf0 !important;
71
+ border-color: #d8c9ad !important;
72
  }
73
 
74
+ /* Generic container shared by every HTMLComponent */
75
+ .cwm-card {
76
+ border: 1px solid #d8c9ad;
77
+ border-radius: 14px;
78
+ padding: 22px 26px;
79
+ box-shadow: 0 8px 24px rgba(107, 74, 42, 0.08);
80
  }
81
+ button.primary, .gr-button-primary {
82
+ background: #a85c2a !important;
83
+ color: #fffbf0 !important;
84
+ font-weight: 600 !important;
85
+ font-size: 16px !important;
86
+ padding: 12px 22px !important;
 
87
  }
88
+ """