| """Custom HTML components — Off-Brand UI built on top of `gr.HTML`. |
| Each component subclasses ``gr.HTML`` and ships its own ``html_template`` and |
| ``css_template``. State is a Python dict; ``render(state)`` returns a complete |
| ``<style>...</style><div>...</div>`` blob ready to drop into the page. Updates |
| flow through normal Gradio event callbacks — call ``Component.render(...)`` in |
| your handler and yield the new HTML string. |
| Pattern adapted from "Gradio HTML components.md": templates substitute |
| ``${value.X}`` tokens against the state dict, every component bundles its own |
| CSS, and components are easy to compose because they're just `gr.HTML` under |
| the hood. |
| """ |
| from __future__ import annotations |
|
|
| import html |
| import json |
| import re |
| from pathlib import Path |
| from typing import Any, ClassVar |
|
|
| import gradio as gr |
|
|
|
|
| |
| |
| |
| TOKEN_RE = re.compile(r"\$\{([^}]+)\}") |
|
|
|
|
| def _resolve(expr: str, scope: dict[str, Any]) -> Any: |
| """Resolve ``value.steps[0].instruction``-style expressions against scope.""" |
| parts = re.split(r"\.|\[|\]", expr) |
| parts = [p for p in parts if p != ""] |
| cur: Any = scope |
| for p in parts: |
| if isinstance(cur, dict) and p in cur: |
| cur = cur[p] |
| elif isinstance(cur, list) and p.isdigit(): |
| cur = cur[int(p)] |
| elif hasattr(cur, p): |
| cur = getattr(cur, p) |
| else: |
| return "" |
| return cur |
|
|
|
|
| def substitute(template: str, state: dict[str, Any]) -> str: |
| scope = {"value": state} |
| def _sub(m: re.Match) -> str: |
| out = _resolve(m.group(1), scope) |
| if out is None: |
| return "" |
| return html.escape(str(out)) if not isinstance(out, str) else html.escape(out) |
| return TOKEN_RE.sub(_sub, template) |
|
|
|
|
| class TemplatedHTML(gr.HTML): |
| """A gr.HTML wrapper that re-renders a template against a state dict.""" |
|
|
| html_template: ClassVar[str] = "" |
| css_template: ClassVar[str] = "" |
|
|
| def __init__(self, value: dict[str, Any] | None = None, **kwargs): |
| self._state: dict[str, Any] = value or {} |
| super().__init__(value=self.render(self._state), **kwargs) |
|
|
| @classmethod |
| def render(cls, state: dict[str, Any]) -> str: |
| body = cls._render_body(state) |
| return f"<style>{cls.css_template}</style>{body}" |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| |
| return substitute(cls.html_template, state) |
|
|
|
|
| |
| |
| |
| class RecipeHero(TemplatedHTML): |
| css_template = """ |
| .cwm-hero { |
| background: #fffbf0 !important; |
| border: 1px solid #d8c9ad; |
| border-radius: 16px; |
| padding: 32px; |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 28px; |
| box-shadow: 0 10px 28px rgba(107, 74, 42, 0.10); |
| } |
| .cwm-hero img { |
| width: 100%; height: 320px; object-fit: cover; border-radius: 12px; |
| background: #efe3c8; |
| } |
| .cwm-hero h1 { |
| font-family: 'Lora', serif; font-size: 38px; color: #6b4a2a !important; |
| margin: 0 0 8px; |
| } |
| .cwm-hero .meta { |
| color: #8a6a3a !important; font-size: 14px; letter-spacing: 0.04em; |
| text-transform: uppercase; margin-bottom: 18px; |
| } |
| .cwm-hero .visual { |
| font-family: 'Lora', serif; font-style: italic; color: #6b4a2a !important; |
| font-size: 17px; line-height: 1.55; |
| } |
| @media (max-width: 720px) { .cwm-hero { grid-template-columns: 1fr; } } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| name = html.escape(state.get("name") or "Pick a dish to get started") |
| cuisine = html.escape(state.get("cuisine") or "") |
| servings = state.get("servings") or 0 |
| time = state.get("total_time_minutes") or 0 |
| visual = html.escape(state.get("final_dish_visual") or "") |
| img_b64 = state.get("final_dish_image_b64") or "" |
| img_path = state.get("final_dish_image_path") or "" |
| if img_b64: |
| img_tag = f'<img src="data:image/png;base64,{img_b64}" alt="final dish"/>' |
| elif img_path: |
| img_tag = f'<img src="/file={html.escape(img_path)}" alt="final dish"/>' |
| else: |
| 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>' |
| return f""" |
| <div class="cwm-hero"> |
| <div>{img_tag}</div> |
| <div> |
| <div class="meta">{cuisine} · {servings} servings · {time} min</div> |
| <h1>{name}</h1> |
| <p class="visual">{visual}</p> |
| </div> |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
| class IngredientChips(TemplatedHTML): |
| css_template = """ |
| .cwm-chips { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0; } |
| |
| /* Forzamos el color del texto y nos aseguramos de que no se herede un color claro */ |
| .cwm-chips .cwm-chip { |
| background: #fbe2d2 !important; |
| color: #6b4a2a !important; |
| border: 1px solid #d8c9ad !important; |
| border-radius: 999px; |
| padding: 6px 14px; |
| font-size: 14px; |
| font-family: 'Inter', sans-serif; |
| display: inline-block; |
| } |
| |
| /* Forzamos el color para los chips de ingredientes faltantes */ |
| .cwm-chips .cwm-chip.missing { |
| background: #fbe2d2 !important; |
| border-color: #c47a52 !important; |
| } |
| |
| .cwm-chips-empty { |
| color: #6b4a2a !important; |
| font-style: italic; |
| padding: 14px 0; |
| } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| have = state.get("have") or [] |
| missing = state.get("missing") or [] |
| if not have and not missing: |
| return '<div class="cwm-chips-empty">No ingredients yet — upload a fridge photo.</div>' |
| parts = ['<div class="cwm-chips">'] |
| for ing in have: |
| parts.append(f'<span class="cwm-chip">{html.escape(str(ing))}</span>') |
| for ing in missing: |
| parts.append(f'<span class="cwm-chip missing">missing: {html.escape(str(ing))}</span>') |
| parts.append('</div>') |
| return "".join(parts) |
|
|
|
|
| |
| |
| |
| |
| class DishOptions(TemplatedHTML): |
| css_template = """ |
| .cwm-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } |
| .cwm-options .cwm-option { |
| background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 12px; |
| padding: 18px; text-align: left; |
| } |
| .cwm-options .cwm-option h3 { |
| font-family: 'Lora', serif; font-size: 19px; color: #6b4a2a !important; |
| margin: 0 0 6px; |
| } |
| .cwm-options .cwm-option p { color: #7a5a35 !important; font-size: 14px; line-height: 1.45; margin: 0; } |
| @media (max-width: 720px) { .cwm-options { grid-template-columns: 1fr; } } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| opts = state.get("options") or [] |
| if not opts: |
| return "" |
| cards = [] |
| for o in opts[:3]: |
| name = html.escape(str(o.get("name", ""))) |
| why = html.escape(str(o.get("why", ""))) |
| cards.append(f'<div class="cwm-option"><h3>{name}</h3><p>{why}</p></div>') |
| return f'<div class="cwm-options">{"".join(cards)}</div>' |
|
|
|
|
| |
| |
| |
| class StepCard(TemplatedHTML): |
| css_template = """ |
| .cwm-steps { display: flex; flex-direction: column; gap: 16px; } |
| .cwm-steps .cwm-step { |
| display: grid; grid-template-columns: 220px 1fr; gap: 22px; |
| background: #fffbf0 !important; border-left: 4px solid #a85c2a; border-radius: 10px; |
| padding: 18px 22px; |
| } |
| .cwm-steps .cwm-step img { |
| width: 220px; height: 160px; object-fit: cover; border-radius: 8px; |
| background: #efe3c8; |
| } |
| .cwm-steps .cwm-step .placeholder { |
| width: 220px; height: 160px; border-radius: 8px; |
| background: linear-gradient(135deg,#efe3c8,#dccaa3); |
| display:flex; align-items:center; justify-content:center; |
| color: #8a6a3a !important; font-family: 'Lora', serif; font-size: 14px; |
| } |
| .cwm-steps .cwm-step h3 { |
| font-family: 'Lora', serif; color: #6b4a2a !important; margin: 0 0 6px; font-size: 22px; |
| } |
| .cwm-steps .cwm-step p { font-size: 16px; line-height: 1.55; color: #4a3722 !important; margin: 0 0 8px; } |
| .cwm-steps .cwm-step .duration { |
| display: inline-block; background: #a85c2a !important; color: #fffbf0 !important; |
| border-radius: 999px; padding: 3px 10px; font-size: 12px; letter-spacing: 0.04em; |
| } |
| .cwm-steps .cwm-step .tip { |
| margin-top: 10px; padding: 10px 12px; background: #fff3d8 !important; |
| border-radius: 8px; font-size: 14px; color: #6b4a2a !important; |
| } |
| .cwm-step .tip::before { content: "💡 "; } |
| @media (max-width: 720px) { .cwm-step { grid-template-columns: 1fr; } .cwm-step img, .cwm-step .placeholder { width: 100%; } } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| steps = state.get("steps") or [] |
| if not steps: |
| return '<div style="color:#b39870;font-style:italic;padding:14px 0;">No steps yet.</div>' |
| cards = [] |
| for s in steps: |
| n = s.get("n", 0) |
| instr = html.escape(s.get("instruction", "")) |
| dur = html.escape(s.get("duration", "")) |
| tip = s.get("tip") |
| visual = html.escape(s.get("visual", "")) |
| img_b64 = s.get("image_b64") or "" |
| img_path = s.get("image_path") or "" |
| if img_b64: |
| img_block = f'<img src="data:image/png;base64,{img_b64}" alt="step {n}"/>' |
| elif img_path: |
| img_block = f'<img src="/file={html.escape(img_path)}" alt="step {n}"/>' |
| else: |
| img_block = f'<div class="placeholder">{visual[:80] if visual else f"Step {n}"}</div>' |
| tip_block = f'<div class="tip">{html.escape(tip)}</div>' if tip else "" |
| cards.append(f""" |
| <div class="cwm-step"> |
| {img_block} |
| <div> |
| <h3>Step {n}</h3> |
| <p>{instr}</p> |
| <span class="duration">{dur}</span> |
| {tip_block} |
| </div> |
| </div> |
| """) |
| return f'<div class="cwm-steps">{"".join(cards)}</div>' |
|
|
|
|
| |
| |
| |
| class NutritionGrid(TemplatedHTML): |
| css_template = """ |
| .cwm-nutri-wrap { margin-top: 10px; } |
| .cwm-nutri-title { |
| font-family: 'Lora', serif; color: #6b4a2a !important; font-size: 22px; margin: 0 0 14px; |
| } |
| .cwm-nutri { |
| display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; |
| } |
| .cwm-nutri .cwm-nutri-cell { |
| background: #fffbf0 !important; border: 1px solid #d8c9ad; border-radius: 10px; |
| padding: 14px 10px; text-align: center; |
| } |
| .cwm-nutri .cwm-nutri-cell .v { |
| font-family: 'Lora', serif; font-size: 24px; font-weight: 700; color: #6b4a2a !important; |
| display: block; |
| } |
| .cwm-nutri .cwm-nutri-cell .l { |
| font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; |
| color: #8a6a3a !important; margin-top: 4px; |
| } |
| @media (max-width: 720px) { .cwm-nutri { grid-template-columns: repeat(2, 1fr); } } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| nutri = state.get("nutrition") or {} |
| cells = [ |
| (nutri.get("calories", 0), "kcal"), |
| (nutri.get("protein_g", 0), "protein"), |
| (nutri.get("carbs_g", 0), "carbs"), |
| (nutri.get("fat_g", 0), "fat"), |
| (nutri.get("fiber_g", 0), "fiber"), |
| ] |
| cell_html = "".join( |
| f'<div class="cwm-nutri-cell"><span class="v">{html.escape(str(v))}</span>' |
| f'<div class="l">{html.escape(label)}</div></div>' |
| for v, label in cells |
| ) |
| return f""" |
| <div class="cwm-nutri-wrap"> |
| <h3 class="cwm-nutri-title">Per serving</h3> |
| <div class="cwm-nutri">{cell_html}</div> |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
| class VerdictBadge(TemplatedHTML): |
| css_template = """ |
| .cwm-verdict { |
| display: flex; align-items: center; gap: 18px; |
| background: #fffbf0 !important; border-radius: 12px; padding: 18px 22px; |
| border: 1px solid #d8c9ad; |
| } |
| .cwm-verdict.go { border-left: 6px solid #4f8b4a; } |
| .cwm-verdict.wait { border-left: 6px solid #d4a23c; } |
| .cwm-verdict.fix { border-left: 6px solid #b94a3a; } |
| .cwm-verdict-pill { |
| font-family: 'Lora', serif; font-weight: 700; font-size: 16px; |
| text-transform: uppercase; letter-spacing: 0.08em; |
| padding: 8px 16px; border-radius: 999px; color: #fffbf0; |
| } |
| .cwm-verdict.go .cwm-verdict-pill { background: #4f8b4a; } |
| .cwm-verdict.wait .cwm-verdict-pill { background: #d4a23c; } |
| .cwm-verdict.fix .cwm-verdict-pill { background: #b94a3a; } |
| .cwm-verdict-text { font-size: 16px; color: #4a3722 !important; line-height: 1.5; } |
| .cwm-verdict-text small { color: #8a6a3a !important; display: block; margin-top: 4px; } |
| .cwm-verdict-empty { |
| color: #b39870; font-style: italic; padding: 14px 0; |
| } |
| """ |
|
|
| @classmethod |
| def _render_body(cls, state: dict[str, Any]) -> str: |
| v = state.get("verdict") |
| if not v: |
| return '<div class="cwm-verdict-empty">Upload a progress photo to get a verdict.</div>' |
| verdict = state.get("verdict", "wait") |
| feedback = html.escape(state.get("feedback", "")) |
| tip = state.get("tip") |
| tip_html = f"<small>{html.escape(tip)}</small>" if tip else "" |
| return f""" |
| <div class="cwm-verdict {html.escape(verdict)}"> |
| <div class="cwm-verdict-pill">{html.escape(verdict)}</div> |
| <div class="cwm-verdict-text">{feedback}{tip_html}</div> |
| </div> |
| """ |
|
|
|
|
| |
| |
| |
| def recipe_to_state(recipe: dict | object) -> dict: |
| """Normalize a Recipe (Pydantic or dict) into the flat dict every |
| component reads. Pydantic models are dumped via ``model_dump`` if present.""" |
| if hasattr(recipe, "model_dump"): |
| d = recipe.model_dump() |
| elif isinstance(recipe, dict): |
| d = recipe |
| else: |
| d = json.loads(json.dumps(recipe, default=lambda o: o.__dict__)) |
| return d |