""" Gradio demo for the SCORM QA Validator. SCORM packages fail silently on LMS upload. A missing launch file or a wrong schema version does not throw an error, it just shows the learner a blank screen after the package goes live. This tool unzips the package, parses imsmanifest.xml, and hands you a pre-flight defect log (severity, location, fix) before it ever reaches Docebo, Workday, or Cornerstone. The result is drawn as fully custom HTML into a single gr.HTML panel: a big verdict card that says whether the package would pass or break, then the defects as a vertical checklist of cards. All of it is controlled by the Python function, not themed defaults. Runs the real package code in scorm_qa/. Run locally: pip install -r requirements.txt && python app.py On Hugging Face Spaces this file is the entry point (app_file: app.py). """ from __future__ import annotations import zipfile import tempfile from html import escape import gradio as gr from scorm_qa.checks import validate_package ACCENT = "#059669" # emerald SEV_COLORS = {"CRITICAL": "#b91c1c", "HIGH": "#ea580c", "MEDIUM": "#ca8a04", "LOW": "#2563eb"} SEV_ICON = {"CRITICAL": "✕", "HIGH": "✕", "MEDIUM": "!", "LOW": "!"} SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} GOOD_MANIFEST = """ 1.2 Course Lesson """ SAMPLES = { "Clean package (1.2, all files present)": { "imsmanifest.xml": GOOD_MANIFEST, "lesson.html": "Hello", }, "Missing launch file (lesson.html absent)": { "imsmanifest.xml": GOOD_MANIFEST, }, "Unsupported schema version (9.9)": { "imsmanifest.xml": GOOD_MANIFEST.replace( "1.2", "9.9"), "lesson.html": "", }, "Dangling extra file not in manifest": { "imsmanifest.xml": GOOD_MANIFEST, "lesson.html": "", "extras/notes.txt": "orphaned asset", }, } def _zip_from_files(files: dict) -> str: tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) with zipfile.ZipFile(tmp, "w") as zf: for name, contents in files.items(): zf.writestr(name, contents) tmp.close() return tmp.name def _verdict_card(defects: list, source: str) -> str: src = escape(source) n = len(defects) blocking = any(d["severity"] in ("CRITICAL", "HIGH") for d in defects) minor = bool(defects) and not blocking if blocking: kind = "fail" icon = "✕" head = "FAIL, would break on LMS upload" sub = f"{n} blocking defect(s) found. Fix these before you upload." elif minor: kind = "warn" icon = "!" head = "PASS with minor issues" sub = f"{n} non-blocking defect(s). The package will load, but tidy these up." else: kind = "ok" icon = "✓" head = "PASS, ready to upload" sub = "No defects found. This package is well-formed." return f"""
{icon}
PRE-FLIGHT CHECK
{head}
{sub}
{src}
""" def _all_clear() -> str: return """
All checks passed
Manifest parses, schema version is supported, every referenced file is present, and nothing is orphaned. Safe to deploy.
""" def _defect_row(d: dict) -> str: sev = d["severity"] color = SEV_COLORS.get(sev, "#6b7280") icon = SEV_ICON.get(sev, "!") location = escape(str(d.get("location", ""))) message = escape(str(d.get("message", ""))) return f"""
{icon}
{escape(sev)} {location}
{message}
""" def _result_html(source: str, defects: list) -> str: verdict = _verdict_card(defects, source) if not defects: body = _all_clear() else: ordered = sorted(defects, key=lambda x: SEV_ORDER.get(x["severity"], 9)) rows = "".join(_defect_row(d) for d in ordered) body = f'
{rows}
' return f'
{verdict}{body}
' EMPTY_STATE = """
📦
Pick a sample or upload a .zip to run the pre-flight check.
Checks manifest, schema version, launch files, and orphaned assets.
""" def run_sample(name: str): if not name: return EMPTY_STATE path = _zip_from_files(SAMPLES[name]) defects, _ = validate_package(path) return _result_html(name, defects) def run_upload(file_obj): if file_obj is None: return EMPTY_STATE defects, _ = validate_package(file_obj.name) return _result_html(file_obj.name.split("/")[-1], defects) CSS = """ :root { --sc-accent:#059669; --sc-bg1:#ecfdf5; --sc-bg2:#eff6ff; --sc-ink:#0f1f1a; --sc-muted:#5b6b64; --sc-card:#ffffff; --sc-line:rgba(15,31,26,.08); --sc-font:'Plus Jakarta Sans','Inter',system-ui,sans-serif; } /* Light lock: HF Spaces default to dark mode, but this UI is designed light. Override Gradio's dark theme variables so it renders light everywhere. */ :root, .dark, gradio-app.dark { color-scheme: light !important; --body-background-fill:#ffffff !important; --background-fill-primary:#ffffff !important; --background-fill-secondary:#f6f6fb !important; --block-background-fill:#ffffff !important; --block-label-background-fill:#ffffff !important; --input-background-fill:#ffffff !important; --border-color-primary:rgba(20,16,40,.12) !important; --body-text-color:#16131f !important; --body-text-color-subdued:#6b6880 !important; --block-title-text-color:#16131f !important; --block-info-text-color:#6b6880 !important; } html, body, gradio-app, .dark { background:#ffffff !important; } .gradio-container { max-width: 820px !important; background: radial-gradient(1200px 500px at 12% -10%, var(--sc-bg1), transparent 60%), radial-gradient(1000px 500px at 112% 8%, var(--sc-bg2), transparent 55%) !important; } .gradio-container, .gradio-container * { font-family: var(--sc-font); } /* Header */ #sc-head { text-align:center; padding: 18px 8px 4px; } #sc-head .sc-pill { display:inline-block; background:var(--sc-ink); color:#fff; border-radius:999px; padding:5px 13px; font-size:.7rem; font-weight:700; letter-spacing:.08em; margin-bottom:14px; } #sc-head h1 { margin:0; font-size:2.05rem; font-weight:800; letter-spacing:-.02em; color:var(--sc-ink); background:linear-gradient(90deg,#059669,#0ea5a4,#2563eb); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; } #sc-head p { margin:10px auto 0; max-width:600px; color:var(--sc-muted); font-size:1.02rem; line-height:1.55; } #sc-head code { background:rgba(5,150,105,.10); color:#047857; padding:1px 6px; border-radius:5px; font-size:.9em; } /* Tabs + controls */ .sc-controls { margin-top:6px; } #sc-go, #sc-go-up { border-radius:14px !important; font-weight:800 !important; font-size:1rem !important; background:linear-gradient(135deg,#059669,#0ea5a4) !important; border:none !important; color:#fff !important; box-shadow:0 10px 26px rgba(5,150,105,.32) !important; transition:transform .12s ease, box-shadow .12s ease !important; } #sc-go:hover, #sc-go-up:hover { transform:translateY(-1px); box-shadow:0 14px 32px rgba(5,150,105,.42) !important; } /* Result wrapper */ .sc-result { animation: sc-fade .35s ease both; } @keyframes sc-fade { from{opacity:0; transform:translateY(8px)} to{opacity:1; transform:none} } /* Verdict card */ .sc-verdict { display:flex; align-items:center; gap:18px; padding:20px 22px; border-radius:20px; background:var(--sc-card); border:1px solid var(--sc-line); position:relative; overflow:hidden; box-shadow:0 18px 44px rgba(15,31,26,.08); } .sc-verdict-badge { width:52px; height:52px; border-radius:16px; flex-shrink:0; display:grid; place-items:center; font-size:1.6rem; font-weight:900; color:#fff; } .sc-verdict-ok { border-left:6px solid #059669; } .sc-verdict-warn { border-left:6px solid #ca8a04; } .sc-verdict-fail { border-left:6px solid #b91c1c; } .sc-verdict-ok .sc-verdict-badge { background:#059669; } .sc-verdict-warn .sc-verdict-badge { background:#ca8a04; } .sc-verdict-fail .sc-verdict-badge { background:#b91c1c; } .sc-verdict-body { flex:1; min-width:0; } .sc-verdict-kicker { font-size:.68rem; font-weight:800; letter-spacing:.1em; color:var(--sc-muted); } .sc-verdict-head { font-size:1.35rem; font-weight:800; color:var(--sc-ink); letter-spacing:-.01em; margin-top:2px; } .sc-verdict-sub { color:var(--sc-muted); font-size:.96rem; margin-top:3px; line-height:1.45; } .sc-verdict-source { font-size:.78rem; color:var(--sc-muted); font-weight:700; max-width:150px; text-align:right; word-break:break-word; flex-shrink:0; } /* Defect checklist */ .sc-list { margin-top:14px; display:flex; flex-direction:column; gap:10px; } .sc-row { display:flex; align-items:flex-start; gap:14px; padding:14px 16px; border-radius:14px; background:var(--sc-card); border:1px solid var(--sc-line); border-left:5px solid var(--sev); box-shadow:0 8px 24px rgba(15,31,26,.05); animation: sc-fade .35s ease both; } .sc-row-icon { width:26px; height:26px; border-radius:50%; flex-shrink:0; display:grid; place-items:center; font-size:.9rem; font-weight:900; color:#fff; background:var(--sev); margin-top:1px; } .sc-row-main { flex:1; min-width:0; } .sc-row-top { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } .sc-chip { display:inline-block; padding:2px 10px; border-radius:999px; font-size:.66rem; font-weight:800; color:#fff; letter-spacing:.04em; } .sc-loc { background:rgba(15,31,26,.06); color:var(--sc-ink); padding:2px 8px; border-radius:6px; font-family:'SFMono-Regular',ui-monospace,Menlo,Consolas,monospace; font-size:.82rem; } .sc-row-msg { margin-top:7px; color:var(--sc-ink); font-size:.95rem; line-height:1.5; } /* All clear */ .sc-clear { margin-top:14px; text-align:center; padding:36px 22px; border-radius:20px; background:var(--sc-card); border:1px solid var(--sc-line); box-shadow:0 12px 32px rgba(5,150,105,.10); } .sc-clear-icon { width:58px; height:58px; margin:0 auto; border-radius:50%; display:grid; place-items:center; font-size:1.8rem; font-weight:900; color:#fff; background:#059669; } .sc-clear-text { margin-top:12px; font-weight:800; color:var(--sc-ink); font-size:1.2rem; } .sc-clear-sub { margin-top:6px; color:var(--sc-muted); font-size:.95rem; line-height:1.55; max-width:480px; margin-left:auto; margin-right:auto; } /* Empty state */ .sc-empty { text-align:center; padding:42px 20px; border-radius:20px; background:var(--sc-card); border:1px dashed var(--sc-line); } .sc-empty-icon { font-size:2.4rem; } .sc-empty-text { margin-top:10px; font-weight:700; color:var(--sc-ink); font-size:1.05rem; } .sc-empty-sub { margin-top:4px; color:var(--sc-muted); font-size:.92rem; } /* Footer */ .sc-footer { margin-top:22px; padding-top:16px; border-top:1px solid var(--sc-line); text-align:center; font-size:.88rem; color:var(--sc-muted); line-height:1.9; } .sc-footer a { text-decoration:none; font-weight:700; color:var(--sc-accent); } .sc-meta { text-align:center; color:var(--sc-muted); font-size:.82rem; margin-top:10px; } .sc-meta code { background:rgba(5,150,105,.10); color:#047857; padding:1px 6px; border-radius:5px; } """ FOOTER = """ """ theme = gr.themes.Soft( primary_hue="emerald", neutral_hue="slate", font=[gr.themes.GoogleFont("Plus Jakarta Sans"), gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"], ) with gr.Blocks(title="SCORM QA Validator", theme=theme, css=CSS) as demo: gr.HTML( '
E-LEARNING QA · PRE-FLIGHT CHECK' "

Would this SCORM package break on upload?

" "

SCORM packages fail silently. A missing launch file or a wrong schema version " "does not throw an error, it just shows the learner a blank screen. This tool opens " "the package, parses imsmanifest.xml, and hands you a defect log " "before it reaches Docebo, Workday, or Cornerstone.

" ) with gr.Tab("Try a built-in sample"): with gr.Group(elem_classes="sc-controls"): sample_dd = gr.Dropdown(choices=list(SAMPLES.keys()), value=list(SAMPLES.keys())[0], label="Sample package") sample_btn = gr.Button("Run pre-flight check", elem_id="sc-go", variant="primary") s_out = gr.HTML(EMPTY_STATE) sample_btn.click(run_sample, inputs=sample_dd, outputs=s_out) demo.load(run_sample, inputs=sample_dd, outputs=s_out) with gr.Tab("Upload your own .zip"): with gr.Group(elem_classes="sc-controls"): up = gr.File(label="SCORM package (.zip)", file_types=[".zip"]) up_btn = gr.Button("Run pre-flight check", elem_id="sc-go-up", variant="primary") u_out = gr.HTML(EMPTY_STATE) up_btn.click(run_upload, inputs=up, outputs=u_out) gr.HTML(FOOTER) gr.HTML('
Runs the real package (scorm_qa/), ' 'the same code the 6-case pytest suite covers.
') if __name__ == "__main__": demo.launch()