prolific_preferences / src /ui /components.py
ehejin's picture
sync w/ detailed repo
0f4326e
"""
Shared CSS injection and all HTML-rendering helpers.
User-supplied text (product titles, descriptions, etc.) always passes through
_safe() before being embedded in HTML to prevent XSS.
"""
import html as _html
import re
import streamlit as st
from src.config import CATEGORY_DISPLAY, FAMILIARITY_USED_LABEL, LIKELIHOOD_LABELS, PREFERENCE_LABELS
# ── Global CSS ────────────────────────────────────────────────────────────────
def inject_css() -> None:
st.markdown("""
<style>
#MainMenu, footer, header { visibility: hidden; }
.block-container { max-width: 860px; padding-top: 2rem; }
/* ── Product cards ───────────────────────────────────────────────────── */
.product-card {
border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: 0.75rem;
color: #1a1a2e !important; /* force dark text regardless of theme */
}
.product-card-a { border: 2px solid #2563eb; background: #eff6ff !important; }
.product-card-b { border: 2px solid #9333ea; background: #faf5ff !important; }
.product-card-single { border: 2px solid #0891b2; background: #ecfeff !important; }
.pc-header {
display: flex; justify-content: space-between;
align-items: flex-start; margin-bottom: 0.6rem; gap: 1rem;
}
.pc-title { font-size: 1.05rem; font-weight: 700; color: #1a1a2e !important; line-height: 1.35; flex: 1; }
.pc-price { font-size: 1.2rem; font-weight: 800; white-space: nowrap; color: #16a34a !important; }
.pc-label {
display: inline-block; font-size: 0.8rem; font-weight: 700;
padding: 0.2rem 0.6rem; border-radius: 99px; margin-bottom: 0.4rem;
}
.pc-label-a { background: #dbeafe !important; color: #1e40af !important; }
.pc-label-b { background: #ede9fe !important; color: #6b21a8 !important; }
.pc-label-single { background: #cffafe !important; color: #155e75 !important; }
.pc-category-badge {
display: inline-block; font-size: 0.7rem; font-weight: 600;
padding: 0.12rem 0.5rem; border-radius: 99px; margin-left: 0.4rem;
background: #f1f5f9 !important; color: #475569 !important;
}
.pc-section { margin-top: 0.5rem; }
.pc-section-title {
font-weight: 600; font-size: 0.82rem; color: #64748b !important;
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.3rem;
}
.pc-desc { font-size: 0.92rem; color: #334155 !important; line-height: 1.6; }
.pc-list { margin: 0; padding-left: 1.2rem; font-size: 0.92rem; color: #334155 !important; line-height: 1.5; }
.pc-list li { margin-bottom: 0.25rem; color: #334155 !important; }
/* ── VS divider ──────────────────────────────────────────────────────── */
.vs-divider {
text-align: center; font-size: 1.3rem; font-weight: 800;
color: #94a3b8; margin: 0.2rem 0;
}
/* ── Progress ────────────────────────────────────────────────────────── */
.progress-wrap { background: #e2e8f0; border-radius: 99px; height: 8px; overflow: hidden; margin-bottom: 0.25rem; }
.progress-fill { background: #2563eb; height: 100%; border-radius: 99px; transition: width 0.3s; }
.progress-label { font-size: 0.82rem; color: #64748b; text-align: right; margin-bottom: 1rem; }
/* ── Chat bubbles ────────────────────────────────────────────────────── */
.chat-wrap { max-height: 480px; overflow-y: auto; margin-bottom: 1rem; padding-right: 4px; }
.bubble {
padding: 0.65rem 0.9rem; border-radius: 12px; margin-bottom: 0.55rem;
font-size: 0.93rem; line-height: 1.55;
color: #1a1a2e !important; /* force dark text regardless of theme */
}
.bubble-ai {
background: #eff6ff !important;
border: 1px solid #93c5fd;
margin-right: 8%;
color: #1a1a2e !important;
}
.bubble-user {
background: #f0fdf4 !important;
border: 1px solid #86efac;
margin-left: 8%;
text-align: right;
color: #1a1a2e !important;
}
.bubble-meta {
font-size: 0.73rem;
color: #64748b !important;
margin-bottom: 0.15rem;
}
/* ── Section headings on background page ────────────────────────────── */
hr.section-divider { border: none; border-top: 2px solid #e2e8f0; margin: 1.5rem 0 1rem 0; }
.section-heading { font-size: 1rem; font-weight: 700; color: #1e40af; margin-bottom: 0.5rem; }
.section-heading-grocery { font-size: 1rem; font-weight: 700; color: #16a34a; margin-bottom: 0.5rem; }
</style>
""", unsafe_allow_html=True)
# ── HTML safety ───────────────────────────────────────────────────────────────
def _safe(text) -> str:
"""Escape user-supplied text for safe embedding in HTML attributes and content."""
s = _html.unescape(str(text))
# Lightly normalise run-on sentences common in Amazon descriptions
s = re.sub(r"([.!?:])([A-Z])", r"\1 \2", s)
s = _html.escape(s)
# Escape markdown characters that Streamlit might render
for ch in ["*", "_", "~", "`", "[", "]"]:
s = s.replace(ch, f"&#{ord(ch)};")
return s.replace("\n", " ")
# ── Product card HTML ─────────────────────────────────────────────────────────
def _product_card_html(product: dict, label: str, compact: bool = False) -> str:
"""
Render one product as an HTML card.
label: "A" | "B" | "single"
compact: limit description height for the in-chat expander view.
"""
title = _safe(product.get("title", "Unknown Product"))
price = product.get("price", "N/A")
desc = product.get("description", [])
features = product.get("features", [])
category = product.get("category", "")
price_str = (
f"${_safe(str(price))}"
if price and price not in ("N/A", "") and not str(price).startswith("$")
else _safe(str(price))
)
if label == "A":
card_cls, lbl_cls, lbl_text = "product-card-a", "pc-label-a", "Product A"
elif label == "B":
card_cls, lbl_cls, lbl_text = "product-card-b", "pc-label-b", "Product B"
else:
card_cls, lbl_cls, lbl_text = "product-card-single", "pc-label-single", "Product"
cat_badge = (
f'<span class="pc-category-badge">'
f'{_safe(CATEGORY_DISPLAY.get(category, category))}'
f'</span>'
if category else ""
)
# Description
if desc:
desc_text = " ".join(d for d in desc if d) if isinstance(desc, list) else str(desc)
overflow = "max-height:180px;overflow-y:auto;" if compact else ""
desc_html = (
f'<div class="pc-section">'
f'<div class="pc-section-title">Description</div>'
f'<div class="pc-desc" style="{overflow}">{_safe(desc_text)}</div>'
f'</div>'
)
else:
desc_html = ""
# Features
if features:
feat_items = [f for f in features if f] if isinstance(features, list) else [str(features)]
if feat_items:
lis = "".join(f"<li>{_safe(f)}</li>" for f in feat_items)
feat_html = (
f'<div class="pc-section">'
f'<div class="pc-section-title">Features</div>'
f'<ul class="pc-list">{lis}</ul>'
f'</div>'
)
else:
feat_html = ""
else:
feat_html = ""
return (
f'<div class="product-card {card_cls}">'
f'<div class="pc-label {lbl_cls}">{lbl_text}{cat_badge}</div>'
f'<div class="pc-header">'
f'<div class="pc-title">{title}</div>'
f'<div class="pc-price">{price_str}</div>'
f'</div>'
f'{desc_html}{feat_html}'
f'</div>'
)
def render_pair_cards(pair: dict, compact: bool = False) -> None:
"""Render Product A and Product B side by side with a VS divider."""
html = (
_product_card_html(pair["product_a"], "A", compact=compact)
+ '<div class="vs-divider">β€” VS β€”</div>'
+ _product_card_html(pair["product_b"], "B", compact=compact)
)
st.markdown(html, unsafe_allow_html=True)
def render_single_card(product: dict, compact: bool = False) -> None:
"""Render a single product card."""
st.markdown(_product_card_html(product, "single", compact=compact), unsafe_allow_html=True)
# ── Progress bar ──────────────────────────────────────────────────────────────
def render_progress(current: int, total: int) -> None:
pct = int((current / total) * 100)
label = "Pair" if total > 1 else "Item"
st.markdown(
f'<div class="progress-wrap">'
f'<div class="progress-fill" style="width:{pct}%"></div>'
f'</div>'
f'<div class="progress-label">{label} {current} of {total}</div>',
unsafe_allow_html=True,
)
# ── Chat bubble renderer ──────────────────────────────────────────────────────
def render_chat_history(turns: list, study_type: str) -> None:
"""
Display conversation turns as styled chat bubbles.
Synthetic user turns contain a raw <choice>N</choice> tag β€” these are
translated to a human-readable label (e.g. "My rating: Neutral") for display.
The raw tag is preserved in state for the model; only the display changes.
"""
labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS
html = '<div class="chat-wrap">'
for turn in turns:
if turn.get("synthetic"):
continue
role = turn.get("role", "")
content = turn.get("content", "")
if role == "assistant":
html += (
f'<div class="bubble-meta">πŸ€– AI Product Agent</div>'
f'<div class="bubble bubble-ai">{_safe(content)}</div>'
)
elif role == "user":
m = re.match(r"^\s*<choice>(\d+)</choice>\s*$", content.strip())
if m:
n = int(m.group(1))
readable = labels.get(n, f"Rating: {n}")
display = f"My rating: {readable}"
else:
display = content
html += (
f'<div class="bubble-meta" style="text-align:right">You</div>'
f'<div class="bubble bubble-user">{_safe(display)}</div>'
)
html += "</div>"
st.markdown(html, unsafe_allow_html=True)
# ── Rating / familiarity helpers ──────────────────────────────────────────────
def familiarity_choices(category: str) -> list:
"""Return the four familiarity options with the used/watched label correct for the category."""
used = FAMILIARITY_USED_LABEL.get(category, "Used it before")
return [
"Never heard of it",
"Heard of it, but not used/purchased",
used,
"Purchased it before",
]
def rating_choices(study_type: str) -> list:
"""Return Likert options formatted as 'Label (N)' for radio widgets."""
labels = PREFERENCE_LABELS if study_type == "preference" else LIKELIHOOD_LABELS
return [f"{v} ({k})" for k, v in labels.items()]
def parse_rating(choice_str: str) -> int:
"""Extract integer from a 'Label text (N)' formatted radio-button value."""
try:
return int(choice_str.split("(")[-1].rstrip(")"))
except Exception:
return 4 # neutral fallback