Islamic2 / ui.py
Ghada-99-Ragab's picture
Upload 21 files
6cb74c6 verified
Raw History Blame Contribute Delete
26.5 kB
"""Arabic-only presentation layer shared by the Gradio app and the static (in-browser) page.
Colour rules: green = verified, red = a real mismatch or a missing reference, amber = needs a human. Verified items
never use red. Every user-facing string is Arabic.
"""
from __future__ import annotations
import html
from typing import List, Optional
_e = html.escape
GROUP_LABEL = {"verified": "موثّق", "mismatch": "غير مطابق", "review": "تحتاج مراجعة بشرية"}
TYPE_LABEL = {"Ayah": "آية قرآنية", "Hadith": "حديث نبوي"}
INDICATORS = [
("composite", "الدرجة المركبة"),
("coverage", "تغطية الكلمات"),
("lcs_ratio", "التسلسل النصي"),
("token_overlap", "تداخل الكلمات"),
("edit_sim", "تشابه الحروف"),
("diacritic_sim", "التشكيل"),
]
METHODS = {
"substring_match": "الاقتباس واردٌ كاملًا في المصدر",
"threshold_pass": "تجاوز عتبة التطابق",
"threshold_fail": "دون عتبة التطابق",
"borderline_multi_cov": "حالة حدّية: عدة مصادر متقاربة",
"borderline_default": "حالة حدّية",
"borderline_low_retrieval": "حالة حدّية: استرجاع ضعيف",
"no_candidates": "لا توجد مصادر مرشحة",
"empty_span": "نص فارغ",
"error": "تعذّرت المعالجة",
}
def _pct(value: float) -> str:
return f"{round(value * 100)}%"
def source_label(source: dict) -> str:
if source["type"] == "Quran":
start, end = source["ayah_start"], source["ayah_end"]
verses = f"{start}" if start == end else f"{start}–{end}"
return f"سورة {source['surah_name']} — الآية {verses}"
return f"حديث رقم {source['hadithID']} — {source['title']}"
def reason_text(reason: dict) -> str:
"""Arabic explanation of a decision code produced by the pipeline."""
code = reason["code"]
src = reason.get("source", "")
if code == "exact_match":
return f"تطابق تام مع {src} بعد تجاهل اختلافات الرسم والتشكيل."
if code == "close_match":
return f"تطابق شبه كامل مع {src}."
if code == "altered_passage":
n = reason.get("n", 0)
how = "تغيّر ترتيب الكلمات" if reason.get("reordered") else f"{n} موضع مختلف"
return f"النص يخالف {src} ({how}). التصحيح المقترح هو نص المصدر حرفيًا ولم يُولَّد."
if code == "weak_match":
return "وُجد مصدر قريب لكن التطابق غير كافٍ للحكم الآلي."
if code == "no_source":
return "لا يوجد في المراجع المضمّنة نصٌّ يشبه هذا الاقتباس، وقد يكون مختلَقًا."
if code == "insufficient_evidence":
return "الأدلة غير كافية: لا مصدر واضح، ودرجة اليقين منخفضة."
if code == "candidate_not_strong":
return f"وُجد مرشح ({src}، قوة المطابقة {round(reason.get('strength', 0) * 100)}%) لكن الدليل لا يكفي للتصحيح الآلي."
if code == "hadith_candidate":
return f"وُجدت رواية مشابهة ({src}) لكن لا يُصحَّح الحديث آليًا لاختلاف الروايات؛ يلزم الرجوع إلى مختص."
return "تعذّرت معالجة هذا الاقتباس آليًا."
def note_text(note: dict) -> str:
n = note.get("n", 0)
code = note["code"]
if code == "diacritic_conflict":
return f"تنبيه: تشكيل {n} كلمة يخالف المصحف (الكلمات صحيحة لكن الحركات مختلفة)."
if code == "orthographic_variant":
return f"ملاحظة: {n} اختلاف إملائي (رسم أو مسافات) لا يغيّر الكلمة."
if code == "hadith_minor_diffs":
return f"ملاحظة: {n} اختلاف طفيف عن أقرب رواية في المراجع."
return ""
def _diff_html(comparison: dict) -> str:
"""Red = words only in the quotation; green = the source words that should be there."""
parts = []
for op in comparison["word_diff"]:
if op["op"] == "equal":
parts.append(f'<span class="w-eq">{_e(op["span"])}</span>')
continue
if op["span"]:
parts.append(f'<span class="w-extra">{_e(op["span"])}</span>')
if op["source"]:
parts.append(f'<span class="w-missing">{_e(op["source"])}</span>')
return " ".join(parts)
def _legend(group: str, comparison: Optional[dict]) -> str:
if group == "verified" or not comparison or all(op["op"] == "equal" for op in comparison["word_diff"]):
return ""
return '<p class="legend"><span class="w-extra">كلمات في الاقتباس تخالف المصدر</span> <span class="w-missing">الصواب من المصدر</span></p>'
def _indicator_table(signals: Optional[dict]) -> str:
if not signals:
return ""
rows = []
for key, label in INDICATORS:
value = float(signals.get(key, 0.0))
rows.append(
f'<div class="ind"><div class="ind-name">{label}</div>'
f'<div class="bar"><span style="width:{_pct(value)}"></span></div><div class="ind-val">{_pct(value)}</div></div>'
)
rows.append(f'<div class="ind"><div class="ind-name">وروده كاملًا في المصدر</div><div class="ind-val wide">{"نعم" if signals.get("is_substring") else "لا"}</div></div>')
return '<div class="indicators">' + "".join(rows) + "</div>"
def _match_percent(span: dict) -> int:
evidence = span["evidence"]
if evidence and evidence.get("comparison"):
return round(evidence["comparison"]["word_similarity"] * 100)
if evidence and evidence.get("signals"):
return round(evidence["signals"]["composite"] * 100)
return 0
def _evidence_panel(span: dict) -> str:
evidence, verification = span["evidence"], span["verification"]
rows = [
f'<div class="ev-row"><b>الاقتباس المكتشف</b><p class="quote">{_e(span["text"])}</p></div>',
f'<div class="ev-row"><b>النوع</b><p>{TYPE_LABEL[span["type"]]}</p></div>',
]
if evidence:
comparison = evidence["comparison"]
rows += [
f'<div class="ev-row"><b>المصدر المرشح</b><p>{_e(source_label(evidence["source"]))}</p></div>',
f'<div class="ev-row"><b>نص المصدر الأصلي</b><p class="quote">{_e(comparison["source_excerpt"])}</p></div>',
f'<div class="ev-row"><b>المقارنة كلمةً بكلمة</b><p class="diff">{_diff_html(comparison)}</p>{_legend(span["group"], comparison)}</div>',
f'<div class="ev-row"><b>مؤشرات التحقق</b>{_indicator_table(evidence.get("signals"))}</div>',
]
if comparison["diacritic_notes"]:
items = "، ".join(f"{_e(n['word'])} ← {_e(n['source_word'])}" for n in comparison["diacritic_notes"])
rows.append(f'<div class="ev-row"><b>اختلافات التشكيل</b><p>{items}</p></div>')
else:
rows.append('<div class="ev-row"><b>المصدر المرشح</b><p>لم يُسترجع أي مصدر.</p></div>')
rows += [
f'<div class="ev-row"><b>درجة اليقين في الحكم</b><p>{_pct(verification["confidence"])} · {METHODS.get(verification["method"], "")}</p></div>',
f'<div class="ev-row"><b>القرار</b><p>{_e(span["status_ar"])}</p></div>',
f'<div class="ev-row"><b>سبب القرار</b><p>{_e(reason_text(span["reason"]))}</p></div>',
]
return "".join(rows)
def _card(span: dict) -> str:
group, evidence = span["group"], span["evidence"]
label = GROUP_LABEL[group]
chip = f'<span class="conf">نسبة المطابقة <b>{_match_percent(span)}%</b></span>'
scan_badge = ('<span class="badge scan" title="اكتُشف بمطابقة النص مع المراجع دون علامات تنصيص">اقتباس غير معلَن</span>'
if span["detection"]["backend"] == "scan" else "")
fields = [f'<div class="field"><label>الاقتباس المكتشف</label><p class="quote">{_e(span["text"])}</p></div>']
if evidence:
comparison = evidence["comparison"]
fields.append(f'<div class="field"><label>المصدر المرشح</label><p>{_e(source_label(evidence["source"]))}</p></div>')
if group != "verified": # a verified quotation equals its source: no need to print it twice
fields.append(
f'<div class="field"><label>المقارنة بالمصدر</label><p class="diff">{_diff_html(comparison)}</p>{_legend(group, comparison)}</div>'
)
else:
fields.append('<div class="field"><label>المصدر المرشح</label><p>لا يوجد</p></div>')
fields.append(f'<p class="reason">{_e(reason_text(span["reason"]))}</p>')
notes = "".join(f'<p class="note">{_e(note_text(n))}</p>' for n in span["notes"] if note_text(n))
action = ""
correction, suggestion = span["correction"], span["suggestion"]
if correction:
action = (
'<div class="action ok"><b>التصحيح المقترح من المصدر</b>'
f'<p class="quote">{_e(correction["display_text"])}</p>'
f'<p class="legend">{_e(source_label(correction["source"]))}</p></div>'
)
elif group == "review":
closest = ""
if suggestion:
closest = (f'<p class="legend">أقرب مصدر وُجد للمراجِع، وليس تصحيحًا آليًا: {_e(source_label(suggestion["source"]))}</p>'
f'<p class="quote small">{_e(suggestion["display_text"][:420])}</p>')
action = ('<div class="action warn"><b>الأدلة غير كافية للتصحيح الآلي.</b> يُوصى بالمراجعة البشرية.' + closest + "</div>")
elif span["status"] == "UNSUPPORTED":
action = '<div class="action bad"><b>لا يوجد مصدر مطابق في المراجع المتاحة.</b> لم يُقترح أي نص بديل.</div>'
return f"""
<div class="qcard {group}">
<div class="qhead">
<span class="idx">{span["id"]}</span>
<span class="badge type">{TYPE_LABEL[span["type"]]}</span>{scan_badge}
<span class="badge st {group}">{label}</span>
{chip}
</div>
{"".join(fields)}
{notes}
{action}
<details class="evidence"><summary>عرض الدليل</summary><div class="ev-body">{_evidence_panel(span)}</div></details>
</div>"""
def _summary(summary: dict) -> str:
tiles = [
(summary["n_spans"], "إجمالي الاقتباسات", ""),
(summary["n_ayah"], "آيات قرآنية", ""),
(summary["n_hadith"], "أحاديث نبوية", ""),
(summary["VERIFIED"], "موثّق", "verified"),
(summary["CORRECTED"] + summary["UNSUPPORTED"], "غير مطابق", "mismatch"),
(summary["HUMAN_REVIEW"], "تحتاج مراجعة", "review"),
]
return '<div class="summary">' + "".join(
f'<div class="tile {cls}{" zero" if value == 0 and cls else ""}"><b>{value}</b><span>{label}</span></div>'
for value, label, cls in tiles
) + "</div>"
def _highlighted_text(result: dict, title: str) -> str:
text, pieces, cursor = result["input_text"], [], 0
for span in result["spans"]:
pieces.append(_e(text[cursor:span["start"]]))
pieces.append(f'<mark class="{span["group"]}">{_e(text[span["start"]:span["end"]])}</mark>')
cursor = span["end"]
pieces.append(_e(text[cursor:]))
legend = ('<div class="hl-legend"><mark class="verified">موثّق</mark><mark class="mismatch">غير مطابق</mark>'
'<mark class="review">مراجعة بشرية</mark></div>')
return f'<div class="highlight"><label>{title}</label><p>' + "".join(pieces) + "</p>" + legend + "</div>"
def final_text(result: dict) -> str:
"""Corrected text with an inline Arabic flag after every quotation that still needs attention."""
text, reports = result["input_text"], result["spans"]
for report in sorted(reports, key=lambda r: r["start"], reverse=True):
if report["status"] == "CORRECTED" and report["correction"]:
text = text[: report["start"]] + report["correction"]["display_text"] + text[report["end"]:]
elif report["status"] == "UNSUPPORTED":
text = text[: report["end"]] + " [⚠ لا يوجد مصدر مطابق]" + text[report["end"]:]
elif report["status"] == "HUMAN_REVIEW":
text = text[: report["end"]] + " [⚠ يحتاج مراجعة بشرية]" + text[report["end"]:]
return text
def _final_block(result: dict) -> str:
summary = result["summary"]
fixed = summary["CORRECTED"]
open_items = summary["UNSUPPORTED"] + summary["HUMAN_REVIEW"]
if fixed == 0 and open_items == 0:
headline = "كل الاقتباسات المكتشفة مطابقة للمصادر."
else:
headline = f"صُحِّح {fixed} اقتباس من نص المصدر، وبقي {open_items} اقتباس يحتاج مراجعة بشرية ومعلَّم بعلامة تنبيه."
text = final_text(result)
return (
'<div class="final"><div class="final-head"><b>النسخة المصحّحة</b>'
f'<button class="copy" type="button" data-text="{_e(text, quote=True)}" '
'onclick="navigator.clipboard&&navigator.clipboard.writeText(this.dataset.text)">نسخ النص</button></div>'
f'<p class="legend">{headline}</p><p class="final-text">{_e(text)}</p></div>'
)
def render_results(result: dict, generated_answer: Optional[str] = None) -> str:
"""HTML report for a pipeline result. Pass the model's answer (mode B) to show it first and label the text as generated."""
header = render_generated_header(generated_answer) if generated_answer is not None else ""
if not result["spans"]:
body = (
'<div class="notice">لم يُعثر على اقتباسات قرآنية أو حديثية في هذا النص. يكتشف النظام الاقتباسات الموضوعة بين علامات '
'تنصيص أو أقواس والمسبوقة بعبارة مثل «قال الله تعالى»، كما يتعرّف على الاقتباسات الطويلة غير المعلَنة بمطابقتها مع المراجع.</div>'
)
return f'<div class="icv">{header}{body}</div>'
generated = generated_answer is not None
title = "إجابة النموذج مع الاقتباسات المكتشفة" if generated else "النص مع الاقتباسات المكتشفة"
parts = [_summary(result["summary"]), _highlighted_text(result, title), "".join(_card(s) for s in result["spans"])]
summary = result["summary"]
if generated or summary["CORRECTED"] or summary["UNSUPPORTED"] or summary["HUMAN_REVIEW"]:
parts.append(_final_block(result))
return f'<div class="icv">{header}{"".join(parts)}</div>'
def render_generated_header(answer: str) -> str:
return f'<div class="generated"><label>إجابة النموذج اللغوي كما وصلت</label><p>{_e(answer)}</p></div>'
def render_message(message: str, kind: str = "info") -> str:
return f'<div class="icv"><div class="notice {kind}">{_e(message)}</div></div>'
# --------------------------------------------------------------------------------------------------------------
# Static markup and CSS
# --------------------------------------------------------------------------------------------------------------
STAR = (
'<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="#E2C06E" stroke-width="1.8" aria-hidden="true">'
'<rect x="14" y="14" width="36" height="36"/><rect x="14" y="14" width="36" height="36" transform="rotate(45 32 32)"/>'
'<circle cx="32" cy="32" r="7" fill="#E2C06E" stroke="none"/></svg>'
)
HERO = f"""
<div class="icv"><div class="hero">
{STAR}
<h1>مُدقِّق المحتوى الإسلامي</h1>
<p class="tagline">تحقّق من آيات القرآن والأحاديث النبوية بالدليل</p>
<p class="sub">يكتشف النظام الاقتباسات داخل أي نص، ويسترجع نصوصها من المصادر، ويقارنها كلمةً بكلمة، ثم يعرض الدليل.
وإذا لم تكفِ الأدلة فلن يختلق تصحيحًا، بل يحيل الحالة إلى المراجعة البشرية.</p>
<div class="flow"><span>كشف</span><i>‹</i><span>استرجاع</span><i>‹</i><span>محاذاة</span><i>‹</i><span>دليل</span><i>‹</i><span>قرار</span></div>
</div></div>
"""
DISCLAIMER = """<div class="icv"><div class="disclaimer">أداة مساعدة للتدقيق النصي وليست فتوى ولا بديلًا عن المراجعة المتخصصة.
النتائج مبنية على مراجع القرآن الكريم والكتب الستة المضمّنة فقط.</div></div>"""
PLACEHOLDER = "الصق هنا النص الذي ولّده نموذج لغوي، ويفضَّل أن تكون الاقتباسات بين علامات تنصيص بعد عبارة مثل «قال الله تعالى» أو «قال رسول الله ﷺ»…"
PROMPT_PLACEHOLDER = "اكتب سؤالك، مثل: اشرح لي فضل الصبر في القرآن والسنة مع ذكر الأدلة."
_PATTERN = (
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='88' height='88' viewBox='0 0 88 88'%3E"
"%3Cg fill='none' stroke='%230F4C3A' stroke-opacity='0.07' stroke-width='1'%3E"
"%3Crect x='22' y='22' width='44' height='44'/%3E%3Crect x='22' y='22' width='44' height='44' transform='rotate(45 44 44)'/%3E"
"%3C/g%3E%3C/svg%3E\")"
)
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700&family=Amiri:wght@400;700&display=swap');
:root { --green:#0F4C3A; --green-2:#17694F; --gold:#B8912F; --gold-2:#E2C06E; --cream:#FBF6EA; --paper:#FFFFFF;
--ink:#1F2933; --muted:#55626D; --line:#E6DCC3; --ok:#1B7A4B; --ok-bg:#EAF6EF; --ok-line:#BFE3CD;
--bad:#B3261E; --bad-bg:#FDECEA; --bad-line:#F4B8B3; --warn:#7A5B0C; --warn-bg:#FFF6DA; --warn-line:#EBD28A; }
.gradio-container, body.icv-page { background:var(--cream) PATTERN !important; font-family:'Cairo','Segoe UI',Tahoma,sans-serif; color:var(--ink); }
.gradio-container { max-width:1060px !important; --body-text-color:#1F2933; --block-background-fill:#FFFFFF; --block-border-color:#E6DCC3;
--input-background-fill:#FFFFFF; --block-label-text-color:#55626D; --button-primary-background-fill:#0F4C3A;
--button-primary-background-fill-hover:#17694F; --button-primary-text-color:#FFFFFF; --button-secondary-background-fill:#FFFFFF;
--button-secondary-text-color:#0F4C3A; --button-secondary-border-color:#B8912F; --color-accent:#B8912F; }
.icv { direction:rtl; text-align:right; color:var(--ink); line-height:1.9; }
.icv .hero { background:linear-gradient(135deg,#0F4C3A,#17694F); border-radius:20px; padding:30px 22px 26px; margin:10px 0 18px; text-align:center;
box-shadow:0 6px 22px rgba(15,76,58,.18); border-bottom:4px solid var(--gold); }
.icv .hero .mark { width:54px; height:54px; display:block; margin:0 auto 4px; }
.icv .hero h1 { font-size:2.3rem; margin:.1rem 0; color:#FFFFFF; font-weight:700; }
.icv .hero .tagline { color:var(--gold-2); font-size:1.2rem; font-weight:600; margin:.1rem 0 .6rem; }
.icv .hero .sub { max-width:720px; margin:0 auto; color:#E9F2EE; font-size:1rem; }
.icv .flow { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:8px; margin-top:16px; }
.icv .flow span { background:rgba(255,255,255,.12); border:1px solid rgba(226,192,110,.55); color:#FFF; border-radius:999px; padding:3px 16px; font-size:.9rem; }
.icv .flow i { color:var(--gold-2); font-style:normal; font-size:1.2rem; }
.icv .disclaimer { font-size:.85rem; color:var(--muted); text-align:center; padding:14px 8px; }
.icv .en-free { direction:rtl; }
.input-area textarea { direction:rtl; text-align:right; font-family:'Amiri','Cairo',serif !important; font-size:1.25rem !important; line-height:2.1 !important; background:#FFFFFF !important; color:#1F2933 !important; }
.llm-row label, .gradio-container label span { font-family:'Cairo',sans-serif; }
.results { direction:rtl; }
.icv .summary { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:10px; margin:8px 0 14px; }
.icv .tile { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:12px 8px; text-align:center; }
.icv .tile b { display:block; font-size:1.8rem; color:var(--green); line-height:1.3; } .icv .tile span { font-size:.92rem; color:var(--muted); }
.icv .tile.verified { background:var(--ok-bg); border-color:var(--ok-line); } .icv .tile.verified b { color:var(--ok); }
.icv .tile.mismatch { background:var(--bad-bg); border-color:var(--bad-line); } .icv .tile.mismatch b { color:var(--bad); }
.icv .tile.review { background:var(--warn-bg); border-color:var(--warn-line); } .icv .tile.review b { color:var(--warn); }
.icv .tile.zero { background:var(--paper); border-color:var(--line); opacity:.6; } .icv .tile.zero b { color:var(--muted); }
.icv .highlight, .icv .generated, .icv .final { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:14px 18px; margin-bottom:14px; }
.icv label { display:block; color:var(--muted); font-size:.82rem; font-weight:600; margin-bottom:4px; }
.icv .highlight p, .icv .generated p, .icv .final-text { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; margin:0; white-space:pre-wrap; }
.icv mark { color:var(--ink); border-radius:6px; padding:1px 5px; }
.icv mark.verified { background:#D5EEDF; } .icv mark.mismatch { background:#F8CFCB; } .icv mark.review { background:#F7E3A6; }
.icv .hl-legend { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; font-size:.78rem; }
.icv .qcard { background:var(--paper); border:1px solid var(--line); border-inline-start:6px solid var(--line); border-radius:16px; padding:16px 18px; margin:12px 0; }
.icv .qcard.verified { border-inline-start-color:var(--ok); } .icv .qcard.mismatch { border-inline-start-color:var(--bad); } .icv .qcard.review { border-inline-start-color:var(--gold); }
.icv .qhead { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:8px; }
.icv .idx { background:var(--green); color:#FFF; border-radius:50%; width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; font-weight:700; font-size:.9rem; }
.icv .badge { padding:2px 14px; border-radius:999px; font-size:.84rem; background:#F3EEDD; border:1px solid var(--line); color:var(--ink); }
.icv .badge.st.verified { background:var(--ok-bg); border-color:var(--ok-line); color:var(--ok); font-weight:600; }
.icv .badge.st.mismatch { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); font-weight:600; }
.icv .badge.st.review { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); font-weight:600; }
.icv .badge.scan { background:#EEF3FB; border-color:#C9D8EE; color:#2F4F7F; }
.icv .conf { margin-inline-start:auto; color:var(--muted); font-size:.88rem; } .icv .conf b { color:var(--green); }
.icv .field { margin:8px 0; } .icv .field p { margin:0; }
.icv .quote { font-family:'Amiri','Cairo',serif; font-size:1.25rem; line-height:2.2; color:var(--ink); } .icv .quote.small { font-size:1.05rem; color:#3a4651; }
.icv .diff { font-family:'Amiri','Cairo',serif; font-size:1.2rem; line-height:2.2; }
.icv .w-extra { background:var(--bad-bg); color:var(--bad); border-radius:4px; padding:0 4px; text-decoration:line-through; }
.icv .w-missing { background:var(--ok-bg); color:var(--ok); border-radius:4px; padding:0 4px; font-weight:700; }
.icv .legend { color:var(--muted); font-size:.82rem; margin:4px 0 0; } .icv .legend span { text-decoration:none; font-size:.8rem; }
.icv .reason { color:var(--muted); font-size:.92rem; margin:6px 0 0; }
.icv .note { color:var(--warn); background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:10px; padding:5px 12px; font-size:.88rem; margin:8px 0 0; }
.icv .action { border-radius:12px; padding:10px 14px; margin-top:10px; }
.icv .action.ok { background:var(--ok-bg); border:1px solid var(--ok-line); } .icv .action.ok b { color:var(--ok); }
.icv .action.warn { background:var(--warn-bg); border:1px solid var(--warn-line); } .icv .action.warn b { color:var(--warn); }
.icv .action.bad { background:var(--bad-bg); border:1px solid var(--bad-line); } .icv .action.bad b { color:var(--bad); }
.icv .evidence { margin-top:12px; border-top:1px dashed var(--line); padding-top:8px; }
.icv .evidence summary { cursor:pointer; color:var(--green); font-weight:700; }
.icv .ev-row { margin:10px 0; } .icv .ev-row b { color:var(--gold); font-size:.88rem; } .icv .ev-row p { margin:2px 0; }
.icv .indicators { display:grid; gap:6px; margin-top:6px; }
.icv .ind { display:grid; grid-template-columns:150px 1fr 48px; gap:10px; align-items:center; font-size:.88rem; }
.icv .bar { background:#EFE8D3; border-radius:999px; height:9px; overflow:hidden; direction:rtl; } .icv .bar span { display:block; height:100%; background:linear-gradient(270deg,var(--green),var(--gold)); }
.icv .ind-val { text-align:left; direction:ltr; color:var(--ink); } .icv .ind-val.wide { grid-column:2 / span 2; text-align:right; direction:rtl; }
.icv .final { border-color:var(--gold); background:#FFFDF6; }
.icv .final-head { display:flex; justify-content:space-between; align-items:center; } .icv .final-head b { color:var(--green); font-size:1.05rem; }
.icv .copy { background:var(--green); color:#FFF; border:0; border-radius:10px; padding:5px 16px; font-family:inherit; cursor:pointer; }
.icv .notice { background:var(--paper); border:1px solid var(--line); border-radius:14px; padding:16px; }
.icv .notice.warn { background:var(--warn-bg); border-color:var(--warn-line); color:var(--warn); } .icv .notice.bad { background:var(--bad-bg); border-color:var(--bad-line); color:var(--bad); }
@media (max-width:640px){ .icv .hero h1{font-size:1.7rem;} .icv .ind{grid-template-columns:104px 1fr 40px;} }
""".replace("PATTERN", _PATTERN)