readthrough / dashboard /components.py
Viney's picture
Claude Opus 5 (1M context)
feat: The Brief renders as the published research note
93f9cc0
Raw History Blame Contribute Delete
35.7 kB
"""Shared UI component helpers used across dashboard tabs."""
from __future__ import annotations
import re
from html import escape
import streamlit as st
from typing import Callable
from dashboard.i18n import t
from dashboard.theme import (
GREEN, RED, AMBER, GRAY, BORDER, TEXT_FAINT,
BG, BG_MUTED, TEXT, TEXT_MUTED,
BULL_BG, BULL_BORDER, BEAR_BG, BEAR_BORDER,
PURPLE, AI_BORDER, AI_COLOR, AI_BADGE_BG,
IMPACT_HIGH_BG, IMPACT_MED_BG, IMPACT_LOW_TEXT,
FS_BODY, FS_META, FS_EYEBROW, FS_SECTION_LABEL,
)
# Span de code CommonMark : run de backticks ouvrant (fence), contenu quelconque,
# fermé par un run de backticks de MÊME longueur, ni précédé ni suivi d'un backtick.
# Alternative : un $ non dΓ©jΓ  Γ©chappΓ©. Un unique re.sub arbitre les deux cas :
# un span matchΓ© est restituΓ© tel quel, un $ hors span devient \$.
_CODE_SPAN_OR_DOLLAR = re.compile(
r"(?P<code>(?P<fence>`+)[\s\S]*?(?<!`)(?P=fence)(?!`))|(?<!\\)\$"
)
def escape_dollars(text: str) -> str:
"""Escape $ so st.markdown does not render inline LaTeX. Code spans untouched."""
if not text:
return text
return _CODE_SPAN_OR_DOLLAR.sub(
lambda m: m.group("code") if m.group("code") is not None else "\\$",
text,
)
# ── Impact badge ─────────────────────────────────────────────────────────────
_IMPACT_DOT_COLORS = {
"HIGH": (IMPACT_HIGH_BG, 3),
"MEDIUM": (IMPACT_MED_BG, 2),
"LOW": (IMPACT_LOW_TEXT, 1),
}
def impact_badge(level: str) -> str:
spec = _IMPACT_DOT_COLORS.get(level)
if not spec:
return ""
color, filled = spec
dots = "".join(
f'<span style="color:{color if i < filled else "#8C968F"};font-size:0.55rem;line-height:1;">●</span>'
for i in range(3)
)
return (
f'<span title="Impact: {level}" style="display:inline-flex;align-items:center;gap:3px;'
f'background:#ECEEE9;border:1px solid #D5DAD4;border-radius:4px;padding:1px 7px;">'
f'<span style="font-size:0.6rem;font-weight:600;color:#8C968F;letter-spacing:0.05em;">impact</span>'
f'{dots}</span>'
)
# ── AI interpretation badge ───────────────────────────────────────────────────
def ai_badge(label: str = "AI Synthesis") -> str:
return (
f'<span style="background:{AI_BADGE_BG};color:{AI_COLOR};'
f'border:1px solid {AI_BORDER};border-radius:4px;'
f'padding:1px 7px;font-size:0.62rem;font-weight:600;">✦ {label}</span>'
)
# ── AI interpretation card wrapper ────────────────────────────────────────────
def interpretation_card(
header_label: str,
content_html: str,
badge_label: str = "AI Synthesis",
) -> str:
return (
f'<div class="primer-card" style="background:{BG};border:1px solid {AI_BORDER};'
f'border-left:4px solid {PURPLE};border-radius:0 12px 12px 0;'
f'padding:20px 24px;margin-bottom:4px;">'
f'<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">'
f'<span style="font-size:{FS_EYEBROW};font-weight:700;letter-spacing:0.1em;'
f'text-transform:uppercase;color:{TEXT_MUTED};">{header_label}</span>'
f'{ai_badge(badge_label)}'
f'</div>'
f'{content_html}'
f'</div>'
)
# ── Source type badge ─────────────────────────────────────────────────────────
_SOURCE_STYLES: dict[str, tuple[str, str, str]] = {
"transcript": ("#DCEBE3", "#1E6B55", "#134A3B"),
"10-q": ("#ECEEE9", "#D5DAD4", "#3E4A44"),
"10-k": ("#ECEEE9", "#D5DAD4", "#3E4A44"),
"filing": ("#ECEEE9", "#D5DAD4", "#3E4A44"),
"news": ("#F4E8D2", "#B8791F", "#B8791F"),
}
def source_badge(s: str) -> str:
if not s:
return ""
key = s.lower().strip()
for pattern, (bg, border, color) in _SOURCE_STYLES.items():
if pattern in key:
return (
f'<span style="background:{bg};color:{color};border:1px solid {border};'
f'border-radius:4px;padding:1px 8px;font-size:0.7rem;font-weight:500;">{s}</span>'
)
return (
f'<span style="background:#ECEEE9;color:#3E4A44;border:1px solid #8C968F;'
f'border-radius:4px;padding:1px 8px;font-size:0.7rem;font-weight:500;">{s}</span>'
)
# ── Eyebrow label ─────────────────────────────────────────────────────────────
def eyebrow_label(text: str, color: str = TEXT_MUTED) -> str:
"""Uppercase micro-label used as a section/card eyebrow."""
return (
f'<span style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;'
f'letter-spacing:0.1em;color:{color};">{text}</span>'
)
# ── Importance marker ─────────────────────────────────────────────────────────
_IMPORTANCE_MAP: dict[str, tuple[str, str, str, str, str]] = {
# impact β†’ (dot_char, dot_color, label_i18n_key, text_color, font_weight)
"HIGH": ("●", "#15201B", "imp_critical", "#15201B", "600"),
"MEDIUM": ("●", "#8C968F", "imp_important", "#8C968F", "500"),
"LOW": ("●", "#8C968F", "imp_context", "#8C968F", "500"),
}
def importance_marker(impact: str) -> str:
"""Monochrome dot + word marker for impact level. No green/red β€” never competes with bull/bear."""
spec = _IMPORTANCE_MAP.get((impact or "").upper())
if not spec:
return ""
dot_char, dot_color, label_key, text_color, fw = spec
text_label = t(label_key)
return (
f'<span style="font-size:{FS_META};font-weight:{fw};color:{text_color};'
f'display:inline-flex;align-items:center;gap:3px;">'
f'<span style="color:{dot_color};">{dot_char}</span>'
f'{text_label}'
f'</span>'
)
# ── Evidence link ────────────────────────────────────────────────────────────
def evidence_link(ref: dict | None, label: str = "") -> str:
"""Anchor to the document a fact was verified against, or "".
Returns "" rather than a bare <a> whenever no usable URL exists. A dead
link is worse than no link: it claims a traceability the data does not
support. `trends`, `sentiment` and `what_to_watch` carry no evidence_ref
at all and therefore always render unlinked.
"""
if not isinstance(ref, dict):
return ""
url = str(ref.get("source_url") or "").strip()
# Scheme allowlist. escape(url, quote=True) prevents attribute breakout but
# leaves the scheme intact, so `javascript:alert(1)` would survive escaping
# unchanged. Refs now flow in from model output and Tavily news results, so
# only http(s) is rendered; everything else -- including a non-string
# source_url, whose str() repr cannot start with a scheme -- yields "".
if not url.lower().startswith(("http://", "https://")):
return ""
text = label or t("evidence_view_source")
return (
f'<a href="{escape(url, quote=True)}" target="_blank" rel="noopener noreferrer" '
f'style="font-size:{FS_META};color:{TEXT_MUTED};text-decoration:underline;'
f'text-underline-offset:2px;">{escape(text)}</a>'
)
# ── Meta row ─────────────────────────────────────────────────────────────────
_RELIABILITY_LABEL_KEYS: dict[str, str] = {
"HIGH": "rel_high",
"MEDIUM": "rel_med",
"LOW": "rel_low",
}
def meta_row(
reliability: str,
source: str,
impact: str,
evidence_ref: dict | None = None,
) -> str:
"""Single subtle attribution line replacing the 3-badge stack.
Example output: ● Critical Β· 10-Q Β· High confidence Β· View source
`evidence_ref` is optional and additive: when it is absent, or carries no
`source_url`, the returned HTML is byte-identical to the pre-traceability
output, so no existing call site changes behaviour.
Returns "" if all of reliability/source/impact are empty/None.
"""
if not reliability and not source and not impact:
return ""
parts: list[str] = []
impact_str = (impact or "").upper()
marker_html = importance_marker(impact_str)
if marker_html:
parts.append(marker_html)
if source:
parts.append(
f'<span style="font-size:{FS_META};color:{TEXT_MUTED};">{source}</span>'
)
if reliability:
rel_key = _RELIABILITY_LABEL_KEYS.get(reliability.upper())
rel_label = t(rel_key) if rel_key else reliability.title()
parts.append(
f'<span style="font-size:{FS_META};color:{TEXT_MUTED};">{rel_label}</span>'
)
link_html = evidence_link(evidence_ref)
if link_html:
parts.append(link_html)
if not parts:
return ""
sep = f'<span style="font-size:{FS_META};color:{TEXT_MUTED};">Β·</span>'
inner = sep.join(parts)
return (
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};margin-top:8px;'
f'display:flex;align-items:center;gap:4px;flex-wrap:wrap;">'
f'{inner}'
f'</div>'
)
# ── Sort by impact ────────────────────────────────────────────────────────────
_IMPACT_ORDER: dict[str, int] = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
def sort_by_impact(items: list[dict]) -> list[dict]:
"""Stable sort: HIGH β†’ MEDIUM β†’ LOW β†’ None/unknown. Returns a new list."""
def _key(item: dict) -> int:
return _IMPACT_ORDER.get((item.get("impact") or "").upper(), 3)
return sorted(items, key=_key)
def _chunk(items: list, size: int) -> list[list]:
"""Split *items* into consecutive groups of at most *size*, order preserved."""
return [items[i:i + size] for i in range(0, len(items), size)]
def weighted_grid(cards: list[Callable[[], None]], columns: int) -> None:
"""Render *cards* (each a zero-arg callable) into rows of *columns*.
A trailing partial row renders its single leftover card full-width
instead of narrowed into an oversized empty st.columns() call. Callers
are responsible for pre-filtering `cards` to only callables that will
actually render content -- this function arranges callables, it does
not inspect brief data (see has_asymmetry/has_swing_factor in
dashboard/verdict.py for the established pattern of that filtering).
"""
for row in _chunk(cards, columns):
if len(row) == 1:
row[0]()
continue
cols = st.columns(len(row), gap="large")
for col, card in zip(cols, row):
with col:
card()
# ── Evidence blockquote ───────────────────────────────────────────────────────
def evidence_quote(snippet: str, bg: str = "#F5F6F3", border_color: str = "#D5DAD4") -> str:
"""Renders a collapsible evidence snippet."""
if not snippet:
return ""
return (
f'<details style="margin:4px 0;">'
f'<summary style="cursor:pointer;font-size:0.68rem;color:#8C968F;font-weight:500;'
f'list-style:none;display:inline-flex;align-items:center;gap:4px;user-select:none;">'
f'<span style="font-size:0.6rem;">β–Ά</span> {t("view_quote")}'
f'</summary>'
f'<div style="margin-top:4px;padding:4px 10px;background:{bg};'
f'border-left:3px solid {border_color};border-radius:0 4px 4px 0;">'
f'<span style="font-size:0.95rem;line-height:1;color:#8C968F;'
f'font-family:Georgia,serif;margin-right:3px;vertical-align:-2px;">"</span>'
f'<span style="font-size:0.75rem;color:#3E4A44;font-style:italic;line-height:1.35;">{snippet}</span>'
f'</div>'
f'</details>'
)
# ── Earnings quality card helpers ─────────────────────────────────────────────
_DIMENSION_LABELS: dict[str, str] = {
"consensus_beat_mix": "Beat Mix",
"guidance_dynamics": "Guidance",
"narrative_vs_numbers": "Narrative",
"segment_mix": "Segment Mix",
"capital_allocation": "Capital",
}
_ASSESSMENT_STYLES: dict[str, tuple[str, str, str]] = {
"positive": (GREEN, BULL_BG, BULL_BORDER),
"neutral": (GRAY, BG_MUTED, BORDER),
"concerning": (RED, BEAR_BG, BEAR_BORDER),
}
# ── Analyst Edge components ───────────────────────────────────────────────────
_DELTA_KIND_META: dict[str, tuple[str, str, str]] = {
# kind β†’ (label, fg_color, bg_color)
"risk_added": ("NEW RISK", "#A63A2B", "#F3DDD8"),
"risk_removed": ("REMOVED RISK", "#3E4A44", "#ECEEE9"),
"risk_reworded": ("REWORDED RISK", "#B8791F", "#F4E8D2"),
"guidance_language_shift": ("GUIDANCE SHIFT", "#1E6B55", "#DCEBE3"),
"term_frequency": ("FREQUENCY SHIFT", "#3E4A44", "#ECEEE9"),
"kpi_dropped": ("DROPPED KPI", "#3E4A44", "#ECEEE9"),
"tone_trend": ("TONE TREND", "#1E6B55", "#DCEBE3"),
"topic_arc": ("TOPIC ARC", "#3E4A44", "#ECEEE9"),
"recurring_evasion": ("RECURRING EVASION", "#A63A2B", "#F3DDD8"),
"topic_fade": ("TOPIC FADE", "#3E4A44", "#ECEEE9"),
}
_SIG_COLORS: dict[str, str] = {"HIGH": "#A63A2B", "MEDIUM": "#B8791F", "LOW": "#8C968F"}
def significance_badge(sig: str) -> str:
color = _SIG_COLORS.get(sig, "#8C968F")
return (
f'<span style="background:{color}1a;color:{color};border:1px solid {color}44;'
f'border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:700;'
f'text-transform:uppercase;letter-spacing:0.06em;">{sig}</span>'
)
# Filing prose runs long β€” a risk-factor paragraph is routinely 800 characters.
# Anything past this is folded behind a disclosure so the card stays scannable.
_REDLINE_PREVIEW_CHARS = 230
def _clamped_passage(text: str, color: str) -> str:
"""Quote `text`, folding everything past the preview behind a disclosure.
The preview is the <summary> and only the remainder lives in the body, so
the passage appears exactly once whether the card is open or closed.
"""
if len(text) <= _REDLINE_PREVIEW_CHARS:
return (
f'<div style="font-size:0.78rem;font-style:italic;color:{color};'
f'line-height:1.45;margin-top:3px;">&ldquo;{text}&rdquo;</div>'
)
# Cut on a word boundary so the preview never ends mid-word.
cut = text.rfind(" ", 0, _REDLINE_PREVIEW_CHARS)
if cut <= 0:
cut = _REDLINE_PREVIEW_CHARS
head, tail = text[:cut], text[cut:].lstrip()
return (
f'<details style="margin-top:3px;">'
f'<summary style="cursor:pointer;list-style:none;">'
f'<span style="font-size:0.78rem;font-style:italic;color:{color};'
f'line-height:1.45;">&ldquo;{head}&hellip;</span>'
f'<span class="primer-more" style="font-size:0.62rem;font-style:normal;font-weight:600;'
f'text-transform:uppercase;letter-spacing:0.06em;color:{TEXT_MUTED};'
f'margin-left:6px;white-space:nowrap;">{t("show_full_passage")}</span>'
f'</summary>'
f'<div style="font-size:0.78rem;font-style:italic;color:{color};'
f'line-height:1.45;">{tail}&rdquo;</div>'
f'</details>'
)
# A reworded filing passage usually opens with an identical boilerplate lead β€”
# risk-factor sections in particular share a long preamble and diverge deep
# inside. Quoting from the top then meant both columns previewed the *same*
# words, which reads as a rendering fault rather than as "nothing changed here
# yet". Past this many identical leading characters, the quote starts near the
# divergence instead.
_COMMON_PREFIX_TRIM = 120
# Words of shared lead kept before the divergence, so the change stays in context.
_DIVERGENCE_CONTEXT = 45
def _focus_divergence(before: str, after: str) -> tuple[str, str]:
"""Drop a long identical opening from both passages, marking the elision.
Returns the passages unchanged when they diverge early enough to be
compared as-is, or when either side is empty (an addition or a removal has
nothing to align against).
"""
if not before or not after:
return before, after
limit = min(len(before), len(after))
index = 0
while index < limit and before[index] == after[index]:
index += 1
if index < _COMMON_PREFIX_TRIM:
return before, after
start = max(0, index - _DIVERGENCE_CONTEXT)
boundary = before.rfind(" ", 0, start)
if boundary != -1:
start = boundary + 1
return "…" + before[start:], "…" + after[start:]
def _redline_block(before: str, after: str) -> str:
"""Render before→after text with redline-style color coding.
The two passages sit side by side rather than stacked: they are meant to be
compared, and stacking two long quotes pushed the next card off-screen.
They wrap back to one column when the card is too narrow to split.
"""
before, after = _focus_divergence(before, after)
sides: list[str] = []
for text, bg, border, label_color, body_color, label in (
(before, "#F3DDD8", "#A63A2B", "#A63A2B", "#A63A2B", "before"),
(after, "#DCEBE3", "#134A3B", "#1E6B55", "#134A3B", "after"),
):
if not text:
continue
sides.append(
f'<div style="flex:1 1 300px;min-width:0;padding:8px 10px;background:{bg};'
f'border-left:3px solid {border};border-radius:0 4px 4px 0;">'
f'<span style="font-size:0.6rem;font-weight:700;text-transform:uppercase;'
f'color:{label_color};letter-spacing:0.07em;">{label}</span>'
f'{_clamped_passage(text, body_color)}'
f'</div>'
)
if not sides:
return ""
return (
f'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start;">'
f'{"".join(sides)}</div>'
)
def section_header(label: str, accent: str = TEXT_MUTED, icon: str = "", compact: bool = False) -> str:
"""Section header: accent tick + semibold dark label + hairline rule.
compact=True trims the top margin for use inside a half-width grid cell
(see weighted_grid) where the page-level 36px top margin looks
disproportionate next to a column neighbor.
"""
prefix = f"{icon}&nbsp;&nbsp;" if icon else ""
margin = "4px 0 10px" if compact else "36px 0 14px"
return (
f'<div style="display:flex;align-items:center;gap:10px;margin:{margin};">'
f'<span style="width:4px;height:15px;border-radius:2px;background:{accent};flex-shrink:0;"></span>'
f'<span style="font-size:{FS_SECTION_LABEL};font-weight:700;text-transform:uppercase;'
f'letter-spacing:0.08em;color:{TEXT};white-space:nowrap;">{prefix}{label}</span>'
f'<div style="flex:1;height:1px;background:{BORDER};"></div>'
f'</div>'
)
def section_divider(label: str, icon: str = "") -> str:
"""Backward-compatible delegate β†’ section_header."""
return section_header(label, icon=icon)
def numbered_list(
items: list[str],
max_items: int | None = None,
more_label: str = "",
) -> str:
"""Render a numbered list with circular badges.
Args:
items: List of text items to render.
max_items: If set, only the first N items are shown.
more_label: If max_items is set and there are more items, show this as a footer line.
Returns:
HTML string (to be rendered with unsafe_allow_html=True).
"""
visible = items[:max_items] if max_items else items
rows_html = ""
for i, item in enumerate(visible, 1):
rows_html += (
f'<div style="display:flex;align-items:flex-start;gap:12px;'
f'padding:12px 0;border-bottom:1px solid {BORDER};">'
f'<span style="display:inline-flex;align-items:center;justify-content:center;'
f'width:22px;height:22px;border-radius:50%;flex-shrink:0;margin-top:1px;'
f'background:{GRAY};color:{BG};font-size:0.7rem;font-weight:700;">{i}</span>'
f'<span style="font-size:{FS_BODY};line-height:1.55;color:{TEXT};">{item}</span>'
f'</div>'
)
footer_html = ""
if more_label and max_items and len(items) > max_items:
footer_html = (
f'<div style="padding:10px 0 4px;font-size:0.75rem;color:{TEXT_MUTED};">'
f'{more_label}</div>'
)
return (
f'<div style="border:1px solid {BORDER};border-radius:12px;'
f'padding:4px 16px;background:{BG};max-width:680px;">'
f'{rows_html}{footer_html}'
f'</div>'
)
# ── Between-the-lines signal-type colors (shared with verdict's Tell card) ───
_SIGNAL_TYPE_COLORS: dict[str, tuple[str, str]] = {
# signal_type β†’ (fg_color, bg_color); labels resolve via t(f"sig_{type}")
"language_drift": ("#1E6B55", "#DCEBE3"), # indigo
"qa_evasion": ("#A63A2B", "#F3DDD8"), # red
"omission": ("#B8791F", "#F4E8D2"), # amber
"emphasis_shift": ("#3E4A44", "#ECEEE9"), # gray
"accounting_quality": ("#3E4A44", "#ECEEE9"), # sky
}
# ── Unified signal card (Decision Stack) ─────────────────────────────────────
# One renderer for every Signal kind; the body adapts per kind. Replaces
# fact_card / tension_card / delta_card / subtext_card / earnings_quality_card.
_KIND_LABEL_KEYS: dict[str, str] = {
"tell": "kind_tell", "tension": "kind_tension", "delta": "kind_delta",
"change": "kind_change", "risk": "kind_risk", "quality": "kind_quality",
"point": "kind_point", "commentary": "kind_commentary",
"note": "kind_note", "news": "kind_news",
}
# Stance is the only colored dimension of the card frame: bull/bear/mixed.
_STANCE_ACCENTS: dict[str, str] = {"bull": GREEN, "bear": RED, "mixed": AMBER}
def _pill(label: str, fg: str, bg: str) -> str:
return (
f'<span style="background:{bg};color:{fg};border:1px solid {fg}33;'
f'border-radius:4px;padding:1px 7px;font-size:0.65rem;font-weight:600;'
f'text-transform:uppercase;letter-spacing:0.06em;">{label}</span>'
)
def _signal_category_chip(sig) -> str:
"""Taxonomy chip: tell signal-types and delta kinds keep their forensic
colors; risk categories and quality dimensions stay neutral gray."""
cat = sig.category
if not cat:
return ""
if sig.kind == "tell" and cat in _SIGNAL_TYPE_COLORS:
fg, bg = _SIGNAL_TYPE_COLORS[cat]
return _pill(t(f"sig_{cat}"), fg, bg)
if sig.kind == "delta" and cat in _DELTA_KIND_META:
_, fg, bg = _DELTA_KIND_META[cat]
return _pill(t(f"dk_{cat}"), fg, bg)
if sig.kind == "risk":
return _pill(cat, GRAY, "#ECEEE9")
if sig.kind == "quality":
return _pill(_DIMENSION_LABELS.get(cat, cat.replace("_", " ").title()), GRAY, "#ECEEE9")
return ""
def _tension_body(sig) -> str:
extra = sig.extra or {}
sides = []
for key_label, fg, reading_key, ev_key in (
("tension_surface", "#134A3B", "bullish_reading", "bullish_evidence"),
("tension_deeper", "#A63A2B", "bearish_reading", "bearish_evidence"),
):
ev = extra.get(ev_key) or {}
ev = ev if isinstance(ev, dict) else {}
sides.append(
f'<div style="flex:1;padding:12px 14px;background:{BG_MUTED};border:1px solid {BORDER};'
f'border-radius:8px;min-width:0;">'
f'<div style="margin-bottom:6px;">{eyebrow_label(t(key_label), fg)}</div>'
f'<div style="font-size:0.82rem;line-height:1.5;color:{TEXT};margin-bottom:8px;">'
f'{extra.get(reading_key, "")}</div>'
f'{evidence_quote(ev.get("evidence_snippet", ""), bg=BG, border_color=BORDER)}'
f'{meta_row(ev.get("reliability", ""), ev.get("source", ""), ev.get("impact", "") or "", evidence_ref=ev.get("evidence_ref"))}'
f'</div>'
)
return f'<div style="display:flex;gap:10px;">{"".join(sides)}</div>'
def _delta_body(sig) -> str:
metric_html = (
f'<div style="font-size:0.72rem;font-weight:600;color:#3E4A44;'
f'background:#ECEEE9;border:1px solid #D5DAD4;border-radius:4px;'
f'padding:4px 8px;margin-bottom:8px;">'
f'<span style="color:{TEXT_MUTED};font-size:0.65rem;font-weight:500;">'
f'{t("computed_label")} Β· </span>{sig.body}'
f'</div>'
) if sig.body else ""
return metric_html + _redline_block(sig.before_text, sig.after_text)
def _tell_body(sig) -> str:
parts = []
if sig.body:
parts.append(
f'<div style="font-size:{FS_BODY};font-style:italic;color:{TEXT};'
f'line-height:1.55;margin-bottom:10px;">{sig.body}</div>'
)
implication = (sig.extra or {}).get("implication", "")
if implication:
parts.append(
f'<div style="border-top:1px solid {BORDER};padding-top:10px;margin-bottom:6px;">'
f'{eyebrow_label(t("btl_implication"), AMBER)}'
f'<div style="font-size:{FS_BODY};color:{TEXT};line-height:1.55;margin-top:4px;">'
f'{implication}</div>'
f'</div>'
)
return "".join(parts)
def _news_body(sig) -> str:
extra = sig.extra or {}
url = extra.get("url", "")
date = extra.get("date", "")
date_html = (
f'<div style="font-size:{FS_META};color:{TEXT_FAINT};margin-bottom:4px;">{date}</div>'
if date else ""
)
title_html = (
f'<a href="{url}" target="_blank" style="font-size:{FS_BODY};font-weight:600;'
f'color:{TEXT};text-decoration:none;border-bottom:1px solid {BORDER};">{sig.headline}</a>'
if url else
f'<div style="font-size:{FS_BODY};font-weight:600;color:{TEXT};">{sig.headline}</div>'
)
body_html = (
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};line-height:1.5;margin-top:6px;">'
f'{sig.body}</div>'
if sig.body else ""
)
return f"{date_html}{title_html}{body_html}"
def signal_card(sig) -> None:
"""Render a normalized Signal (see dashboard/signal_feed.py) as one card."""
from dashboard.signal_feed import EXPERIMENTAL_AI_KINDS, HEURISTIC_KINDS
accent = _STANCE_ACCENTS.get(sig.stance or "", BORDER)
# ── Header row: taxonomy chips left, period + AI badge right ───────────
left_bits = [
_pill(t(_KIND_LABEL_KEYS.get(sig.kind, "kind_note")), GRAY, "#ECEEE9"),
_signal_category_chip(sig),
]
if sig.is_new:
left_bits.append(_pill(t("new_badge"), RED, BEAR_BG))
if sig.significance == "HIGH":
# Preserve item-level ranking without exposing an uncalibrated HIGH / MED / LOW scale.
left_bits.append(_pill("Material", TEXT, BG_MUTED))
right_bits = []
if sig.period_range:
right_bits.append(
f'<span style="font-size:0.65rem;color:{TEXT_MUTED};">{sig.period_range}</span>'
)
if sig.kind in EXPERIMENTAL_AI_KINDS:
right_bits.append(ai_badge("AI Β· experimental"))
elif sig.kind in HEURISTIC_KINDS:
right_bits.append(_pill("Heuristic Β· validate", GRAY, "#ECEEE9"))
header = (
f'<div style="display:flex;align-items:center;justify-content:space-between;'
f'gap:8px;margin-bottom:10px;flex-wrap:wrap;">'
f'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">'
f'{"".join(b for b in left_bits if b)}</div>'
f'<div style="display:flex;align-items:center;gap:6px;">'
f'{"".join(right_bits)}</div>'
f'</div>'
)
# ── Headline ────────────────────────────────────────────────────────────
headline_html = ""
if sig.headline and sig.kind != "news":
headline_html = (
f'<div style="font-size:{FS_BODY};font-weight:600;color:{TEXT};'
f'line-height:1.5;margin-bottom:6px;">{sig.headline}</div>'
)
# ── Quality assessment chip rides next to the headline ─────────────────
if sig.kind == "quality":
assessment = (sig.extra or {}).get("assessment", "neutral")
color, bg, _border = _ASSESSMENT_STYLES.get(assessment, _ASSESSMENT_STYLES["neutral"])
icon = {"positive": "β–²", "neutral": "β€”", "concerning": "β–Ό"}.get(assessment, "β€”")
label = t(f"quality_{assessment}") if assessment in ("positive", "neutral", "concerning") else assessment.title()
headline_html = (
f'<div style="margin-bottom:8px;">'
f'<span style="display:inline-flex;align-items:center;gap:4px;'
f'background:{color}1a;color:{color};border:1px solid {color}44;'
f'border-radius:4px;padding:2px 8px;font-size:0.65rem;font-weight:700;">'
f'{icon}&nbsp;{label}</span>'
f'</div>'
) + headline_html
# ── Body by kind ────────────────────────────────────────────────────────
if sig.kind == "tension":
body_html = _tension_body(sig)
elif sig.kind == "delta":
body_html = _delta_body(sig)
elif sig.kind == "tell":
body_html = _tell_body(sig)
elif sig.kind == "news":
body_html = _news_body(sig)
elif sig.body:
body_html = (
f'<div style="font-size:{FS_BODY};line-height:1.55;color:{TEXT};'
f'margin-bottom:8px;">{sig.body}</div>'
)
else:
body_html = ""
# ── Footer: evidence + attribution (tension carries these per side) ────
footer_html = ""
if sig.kind != "tension":
footer_html = (
evidence_quote(sig.evidence_snippet, bg=BG_MUTED, border_color=BORDER)
+ meta_row(sig.reliability, sig.source, sig.impact or "")
)
# ── Dedup cross-reference β€” this canonical card absorbed near-duplicate
# facts from other sections; note where else the same fact was classified
# instead of showing it as 3-4 separate cards.
also_in = (sig.extra or {}).get("also_in") or []
if also_in:
labels = " Β· ".join(t(_KIND_LABEL_KEYS.get(k, "kind_note")).upper() for k in also_in)
footer_html += (
f'<div style="font-size:0.65rem;color:{TEXT_MUTED};margin-top:6px;">'
f'{t("also_classified_as")} {labels}</div>'
)
st.markdown(
f'<div class="primer-card" style="background:{BG};border:1px solid {BORDER};'
f'border-left:3px solid {accent};border-radius:0 10px 10px 0;'
f'padding:14px 18px;margin-bottom:10px;max-width:1000px;">'
f'{header}{headline_html}{body_html}{footer_html}'
f'</div>',
unsafe_allow_html=True,
)
# ── History table ─────────────────────────────────────────────────────────────
# The one device the dashboard did not have. _verdict_strip (quality_view) can
# say a streak happened; it cannot say the cushion behind that streak fell from
# +20% to +4%. This shows the figures per row, which is the only way a trend in
# magnitude becomes visible.
_TONE_COLOR = {"up": GREEN, "down": RED, "flag": AMBER, "dim": TEXT_FAINT}
def history_table(
headers: list[str],
rows: list[list[tuple[str, str | None]]],
caption: str = "",
source: str = "",
) -> str:
"""Render a compact numeric history.
Each cell is ``(text, tone)`` where tone is None or one of up / down /
flag / dim. The first column is left-aligned as a label; every other column
is right-aligned with tabular figures so digits line up down the column.
Wrapped in its own horizontal scroller: a wide table must never make the
whole page scroll sideways.
"""
if not rows:
return ""
head = "".join(
f'<th style="font-size:{FS_EYEBROW};font-weight:700;text-transform:uppercase;'
f'letter-spacing:0.07em;color:{TEXT_MUTED};padding:0 0 8px 16px;'
f'text-align:{"left" if i == 0 else "right"};border-bottom:1px solid {BORDER};'
f'white-space:nowrap;">{escape(str(h))}</th>'
for i, h in enumerate(headers)
)
body = ""
for row in rows:
cells = ""
for i, cell in enumerate(row):
text, tone = cell if isinstance(cell, (tuple, list)) else (cell, None)
color = _TONE_COLOR.get(tone or "", TEXT)
align = "left" if i == 0 else "right"
pad = "7px 0 7px 0" if i == 0 else "7px 0 7px 16px"
cells += (
f'<td style="padding:{pad};text-align:{align};color:{color};'
f'border-bottom:1px solid {BORDER};white-space:nowrap;">'
f'{escape(str(text))}</td>'
)
body += f"<tr>{cells}</tr>"
caption_html = (
f'<div style="font-size:{FS_META};color:{TEXT_MUTED};padding-bottom:10px;">'
f'{escape(caption)}</div>'
if caption else ""
)
source_html = (
f'<div style="font-size:{FS_META};color:{TEXT_FAINT};padding-top:9px;">'
f'{escape(source)}</div>'
if source else ""
)
return (
f'{caption_html}'
f'<div style="overflow-x:auto;">'
f'<table style="width:100%;border-collapse:collapse;'
f'font-variant-numeric:tabular-nums;font-size:0.82rem;'
f'font-family:ui-monospace,SFMono-Regular,Consolas,monospace;">'
f'<thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>'
f'</div>{source_html}'
)
def rail_card(body: str, accent: str, meta: str = "") -> str:
"""A statement with an accent rail down its left edge.
Promoted out of quality_view.render_expectations, which had this shape
inline. The rail means the block is a reading of the figures rather than
one of them -- so it stays reserved for that, and does not become a
decoration for arbitrary panels.
"""
meta_html = (
f'<div style="font-size:{FS_META};color:{TEXT_FAINT};margin-top:8px;">{meta}</div>'
if meta else ""
)
return (
f'<div class="primer-card" style="background:{BG};border:1px solid {BORDER};'
f'border-left:3px solid {accent};border-radius:0 10px 10px 0;'
f'padding:16px 20px;max-width:820px;margin-bottom:6px;">'
f'<div style="font-size:{FS_BODY};line-height:1.6;color:{TEXT};">{body}</div>'
f'{meta_html}</div>'
)