Ryadg commited on
Commit
57ebbd6
·
1 Parent(s): 92295c9

feat: amphitheater hero with mouse-tracking robot professor, fixed particles

Browse files
Files changed (3) hide show
  1. app.py +74 -0
  2. core/image_gen.py +7 -21
  3. ui/index.html +232 -379
app.py CHANGED
@@ -435,6 +435,78 @@ BRIDGE_JS = """() => {
435
  if (btn) { btn.click(); return true; } return false;
436
  }
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  // ── Score ring ─────────────────────────────────────────────────────────────
439
  function updateScore() {
440
  const arc = $('score-arc');
@@ -730,6 +802,8 @@ BRIDGE_JS = """() => {
730
  // ── Main polling loop ──────────────────────────────────────────────────────
731
  setInterval(function() {
732
  wireButtons();
 
 
733
 
734
  const statusEl = document.querySelector('#hidden-status-output textarea');
735
  if (statusEl && statusEl.value) applyStatus(statusEl.value);
 
435
  if (btn) { btn.click(); return true; } return false;
436
  }
437
 
438
+ // ── Hero: professor eye tracking ───────────────────────────────────────────
439
+ // (Lives here, not in index.html: <script> tags injected via gr.HTML's
440
+ // innerHTML are never executed by browsers.)
441
+ function initHeroProfessor() {
442
+ const prof = $('professor');
443
+ if (!prof || prof._pp) return;
444
+ prof._pp = true;
445
+ const pupils = prof.querySelectorAll('.prof-pupil');
446
+ let tx = 0, ty = 0, cx = 0, cy = 0;
447
+ document.addEventListener('mousemove', e => {
448
+ const r = prof.getBoundingClientRect();
449
+ // eyes sit in the head, around the top third of the character
450
+ const ox = r.left + r.width / 2, oy = r.top + r.height * 0.32;
451
+ const dx = e.clientX - ox, dy = e.clientY - oy;
452
+ const d = Math.min(4, Math.hypot(dx, dy) / 40); // max 4px displacement
453
+ const a = Math.atan2(dy, dx);
454
+ tx = Math.cos(a) * d; ty = Math.sin(a) * d;
455
+ });
456
+ (function track() {
457
+ cx += (tx - cx) * 0.18; cy += (ty - cy) * 0.18;
458
+ const t = 'translate(' + cx.toFixed(2) + 'px,' + cy.toFixed(2) + 'px)';
459
+ pupils.forEach(p => { p.style.transform = t; });
460
+ requestAnimationFrame(track);
461
+ })();
462
+ }
463
+
464
+ // ── Background particles (canvas is in gr.HTML markup; animation here) ────
465
+ function initParticles() {
466
+ const canvas = $('particles');
467
+ if (!canvas || canvas._pp) return;
468
+ canvas._pp = true;
469
+ const ctx = canvas.getContext('2d');
470
+ let W, H;
471
+ const rand = (a, b) => a + Math.random() * (b - a);
472
+ function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
473
+ window.addEventListener('resize', resize);
474
+ resize();
475
+ const parts = Array.from({length: 65}, () => ({
476
+ x: rand(0, W), y: rand(0, H),
477
+ vx: rand(-.25, .25), vy: rand(-.25, .25),
478
+ r: rand(1, 2.2),
479
+ purple: Math.random() > .5,
480
+ alpha: rand(.15, .55),
481
+ }));
482
+ (function draw() {
483
+ ctx.clearRect(0, 0, W, H);
484
+ for (let i = 0; i < parts.length; i++) {
485
+ const p = parts[i];
486
+ p.x += p.vx; p.y += p.vy;
487
+ if (p.x < -5) p.x = W + 5; if (p.x > W + 5) p.x = -5;
488
+ if (p.y < -5) p.y = H + 5; if (p.y > H + 5) p.y = -5;
489
+ for (let j = i + 1; j < parts.length; j++) {
490
+ const q = parts[j];
491
+ const dx = p.x - q.x, dy = p.y - q.y;
492
+ const dist = Math.hypot(dx, dy);
493
+ if (dist < 110) {
494
+ ctx.beginPath();
495
+ ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
496
+ ctx.strokeStyle = 'rgba(124,58,237,' + ((1 - dist / 110) * 0.12) + ')';
497
+ ctx.lineWidth = 1;
498
+ ctx.stroke();
499
+ }
500
+ }
501
+ ctx.beginPath();
502
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
503
+ ctx.fillStyle = 'rgba(' + (p.purple ? '124,58,237' : '6,182,212') + ',' + p.alpha + ')';
504
+ ctx.fill();
505
+ }
506
+ requestAnimationFrame(draw);
507
+ })();
508
+ }
509
+
510
  // ── Score ring ─────────────────────────────────────────────────────────────
511
  function updateScore() {
512
  const arc = $('score-arc');
 
802
  // ── Main polling loop ──────────────────────────────────────────────────────
803
  setInterval(function() {
804
  wireButtons();
805
+ initHeroProfessor();
806
+ initParticles();
807
 
808
  const statusEl = document.querySelector('#hidden-status-output textarea');
809
  if (statusEl && statusEl.value) applyStatus(statusEl.value);
core/image_gen.py CHANGED
@@ -2,7 +2,6 @@
2
  Concept image generation via FLUX.2-klein.
3
  """
4
 
5
- import re
6
  import threading
7
  from functools import lru_cache
8
 
@@ -45,29 +44,16 @@ def _load_pipeline():
45
  return pipe
46
 
47
 
48
- def _clean_concept(question: str) -> str:
49
- # FLUX.2 renders any sentence it sees as a title in the image, so strip
50
- # the interrogative phrasing and keep only the underlying concept.
51
- c = question.strip().replace("\n", " ")
52
- c = re.sub(
53
- r"^(please\s+)?(explain|describe|discuss|define|state|give|provide|name)"
54
- r"(\s+(why|how|what|when|where|the|a|an))?\s+",
55
- "", c, flags=re.IGNORECASE,
56
- )
57
- c = re.sub(r"^(why|how|what|when|where)\s+(is|are|does|do|can|would)\s+", "", c, flags=re.IGNORECASE)
58
- return c.rstrip("?.! ")[:200]
59
-
60
-
61
  def generate_concept_image(concept: str) -> Image.Image:
 
 
62
  pipe = _load_pipeline()
63
  prompt = (
64
- "A purely pictorial, completely wordless flat-design illustration "
65
- f"visually representing the concept of {_clean_concept(concept)}, "
66
- "expressed only through icons, symbols, arrows and visual metaphors. "
67
- "The image contains no typography, no letters, no numbers, no labels, "
68
- "no captions and no writing of any kind. "
69
- "Dark navy background, purple and cyan accent colors, "
70
- "clean minimalist scientific illustration style."
71
  )
72
  result = pipe(
73
  prompt=prompt,
 
2
  Concept image generation via FLUX.2-klein.
3
  """
4
 
 
5
  import threading
6
  from functools import lru_cache
7
 
 
44
  return pipe
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def generate_concept_image(concept: str) -> Image.Image:
48
+ # `concept` is intentionally ignored: FLUX.2 renders any text it sees
49
+ # into the image, so we use a fixed purely-visual celebration prompt.
50
  pipe = _load_pipeline()
51
  prompt = (
52
+ "A cute minimalist illustration celebrating learning and success, "
53
+ "no text, no words, no letters, no labels, purely visual, "
54
+ "soft glowing shapes, stars, abstract celebration, "
55
+ "dark navy background, purple and cyan colors, "
56
+ "warm and encouraging mood, flat design"
 
 
57
  )
58
  result = pipe(
59
  prompt=prompt,
ui/index.html CHANGED
@@ -53,26 +53,189 @@
53
  display: flex; flex-direction: column; gap: 20px;
54
  }
55
 
56
- /* ── Header ───────────────────────────────────────────────────────────── */
57
  .hero {
58
- text-align: center;
59
- padding: 48px 20px 36px;
60
  animation: fadeUp .6s ease both;
61
  }
62
- .hero-icon { font-size: 3.2rem; display: block; margin-bottom: 12px;
63
- filter: drop-shadow(0 0 16px rgba(124,58,237,.6)); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  .hero-title {
65
- font-size: clamp(2.6rem, 8vw, 4.2rem);
66
  font-weight: 900; line-height: 1;
67
  background: linear-gradient(135deg, var(--purple-l) 0%, var(--cyan-l) 100%);
68
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
69
  background-clip: text;
70
  letter-spacing: -1.5px;
71
- margin-bottom: 10px;
 
 
72
  }
73
  .hero-sub {
74
- color: var(--muted); font-size: 1rem; font-weight: 400;
75
  letter-spacing: .01em;
 
76
  }
77
 
78
  /* ── Card ─────────────────────────────────────────────────────────────── */
@@ -379,6 +542,13 @@
379
  @keyframes popIn { from { opacity: 0; transform: scale(.8); } to { opacity: 1; transform: scale(1); } }
380
  @keyframes spin { to { transform: rotate(360deg); } }
381
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
 
 
 
 
 
 
 
382
 
383
  .hidden { display: none !important; }
384
  .fade-in-up { animation: fadeUp .4s ease both; }
@@ -434,8 +604,10 @@
434
  @media (max-width: 520px) {
435
  #app { padding: 20px 14px 60px; }
436
  .card { padding: 20px 18px; }
437
- .hero { padding: 32px 12px 24px; }
438
- .hero-title { font-size: 2.4rem; }
 
 
439
  .controls-row { gap: 8px; }
440
  .answer-actions { flex-direction: column; }
441
  .answer-actions button { width: 100%; justify-content: center; }
@@ -444,11 +616,54 @@
444
  <canvas id="particles"></canvas>
445
 
446
  <div id="app">
447
- <!-- Hero -->
448
  <header class="hero">
449
- <span class="hero-icon">📄</span>
450
- <h1 class="hero-title">PaperProf</h1>
451
- <p class="hero-sub">Upload your course. Get quizzed by AI.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  </header>
453
 
454
  <!-- ── Quiz card ────────────────────────────────────────────────────── -->
@@ -584,370 +799,8 @@
584
  </div>
585
 
586
  <script>
587
- // Script intentionally left minimal all quiz logic lives in BRIDGE_JS
588
- // injected via demo.load(js=BRIDGE_JS) which actually executes.
589
- // innerHTML-injected <script> tags are never run by browsers.
590
-
591
- // (all logic removed — lives in BRIDGE_JS)
592
- // placeholder to keep closing tag:
593
  void 0;
594
- /*
595
- const emptyState = $('empty-state');
596
- const questionArea = $('question-area');
597
- const qText = $('q-text');
598
- const qLoading = $('q-loading');
599
- const qLoadingMsg = $('q-loading-msg');
600
- const answerInput = $('answer-input');
601
- const charCount = $('char-count');
602
- const newQBtn = $('new-q-btn');
603
- const startBtn = $('start-btn');
604
- const submitBtn = $('submit-btn');
605
- const submitText = $('submit-btn-text');
606
- const submitSpinner= $('submit-spinner');
607
- const endBtn = $('end-btn');
608
- const scoreArc = $('score-arc');
609
- const scoreLabel = $('score-label');
610
-
611
- const feedbackCard = $('feedback-card');
612
- const verdictBadge = $('verdict-badge');
613
- const feedbackBody = $('feedback-body');
614
- const sourceText = $('source-text');
615
-
616
- const modal = $('modal');
617
- const modalScoreBig= $('modal-score-big');
618
- const modalScoreLabel = $('modal-score-label');
619
- const modalMessage = $('modal-message');
620
- const modalHistory = $('modal-history');
621
- const modalClose = $('modal-close');
622
-
623
- // ───────────────────────────────────────────────────────────────────────────
624
- // API helpers
625
- // ───────────────────────────────────────────────────────────────────────────
626
- async function apiQuestion(chunk, language, difficulty) {
627
- const r = await fetch('/api/question', {
628
- method: 'POST',
629
- headers: { 'Content-Type': 'application/json' },
630
- body: JSON.stringify({ chunk, language, difficulty }),
631
- });
632
- const d = await r.json();
633
- if (!r.ok) throw new Error(d.detail || 'Failed to generate question');
634
- return d;
635
- }
636
-
637
- async function apiEvaluate(question, chunk, answer, language) {
638
- const r = await fetch('/api/evaluate', {
639
- method: 'POST',
640
- headers: { 'Content-Type': 'application/json' },
641
- body: JSON.stringify({ question, chunk, answer, language }),
642
- });
643
- const d = await r.json();
644
- if (!r.ok) throw new Error(d.detail || 'Failed to evaluate answer');
645
- return d;
646
- }
647
-
648
- // ───────────────────────────────────────────────────────────────────────────
649
- // UI helpers
650
- // ───────────────────────────────────────────────────────────────────────────
651
- function setLoadingBtn(btn, textEl, spinner, loading, label) {
652
- btn.disabled = loading;
653
- textEl.textContent = label || textEl.textContent;
654
- spinner.classList.toggle('hidden', !loading);
655
- }
656
-
657
- function updateScore() {
658
- const n = S.correct, d = S.total;
659
- scoreLabel.textContent = d === 0 ? '—' : `${n}/${d}`;
660
- const pct = d === 0 ? 0 : n / d;
661
- const circ = 138.2;
662
- scoreArc.style.strokeDashoffset = circ * (1 - pct);
663
- const color = pct >= 0.7 ? '#10B981' : pct >= 0.4 ? '#F59E0B' : '#EF4444';
664
- scoreArc.style.stroke = d === 0
665
- ? 'url(#scoreGrad)'
666
- : color;
667
- }
668
-
669
- function extractVerdict(text) {
670
- const lower = text.toLowerCase();
671
- const lines = lower.split('\n');
672
- for (const l of lines) {
673
- if (l.includes('verdict')) {
674
- if (l.includes('partially') || l.includes('partiellement')) return 'partial';
675
- if (l.includes('incorrect')) return 'incorrect';
676
- if (l.includes('correct')) return 'correct';
677
- }
678
- }
679
- if (lower.slice(0,200).includes('incorrect')) return 'incorrect';
680
- if (lower.slice(0,200).includes('partially') || lower.slice(0,200).includes('partiellement')) return 'partial';
681
- if (lower.slice(0,200).includes('correct')) return 'correct';
682
- return 'unknown';
683
- }
684
-
685
- function renderFeedback(text) {
686
- const sectionLabels = ['', 'Verdict', 'What was good', 'What was missing', 'Model answer'];
687
- const frLabels = ['', 'Verdict', 'Ce qui était bien', 'Ce qui manquait', 'Réponse modèle'];
688
- const labels = S.language === 'Français' ? frLabels : sectionLabels;
689
- const sections = [];
690
- let m;
691
- const re = /(\d+)\.\s*([^:\n]*)[::]?\s*([\s\S]*?)(?=\n\d+\.|$)/g;
692
- while ((m = re.exec(text)) !== null) {
693
- sections.push({ num: parseInt(m[1]), header: m[2].trim(), body: m[3].trim() });
694
- }
695
-
696
- if (sections.length >= 2) {
697
- feedbackBody.innerHTML = sections.map(s => {
698
- const label = labels[s.num] || s.header || `Part ${s.num}`;
699
- return `<div class="section">
700
- <span class="section-num">${label}</span>
701
- <div class="section-content">${s.body.replace(/\n/g,'<br>')}</div>
702
- </div>`;
703
- }).join('');
704
- } else {
705
- feedbackBody.innerHTML = `<div class="feedback-raw">${
706
- text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
707
- }</div>`;
708
- }
709
- }
710
-
711
- function showFeedback(text, chunk) {
712
- const verdict = extractVerdict(text);
713
- verdictBadge.className = `verdict-badge ${verdict === 'unknown' ? '' : verdict}`;
714
- const labels = {
715
- correct: S.language === 'Français' ? '✓ Correct' : '✓ Correct',
716
- partial: S.language === 'Français' ? '~ Partiellement correct' : '~ Partially Correct',
717
- incorrect: S.language === 'Français' ? '✗ Incorrect' : '✗ Incorrect',
718
- unknown: '— Unknown',
719
- };
720
- verdictBadge.textContent = labels[verdict] || labels.unknown;
721
-
722
- renderFeedback(text);
723
- sourceText.textContent = chunk;
724
- feedbackCard.classList.remove('hidden');
725
- feedbackCard.classList.add('fade-in-up');
726
- feedbackCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
727
- return verdict;
728
- }
729
-
730
- function showModal() {
731
- const pct = S.total > 0 ? Math.round((S.correct / S.total) * 100) : 0;
732
- modalScoreBig.textContent = `${S.correct}/${S.total}`;
733
- modalScoreLabel.textContent = S.total === 1 ? 'question answered' : 'questions answered';
734
-
735
- const msgs = pct >= 90
736
- ? ["Outstanding! You've mastered this material. 🌟", "Incroyable ! Tu maîtrises ce cours. 🌟"]
737
- : pct >= 70
738
- ? ["Great work — you have a solid grasp of this content. 💪", "Bon travail — tu maîtrises bien le contenu. 💪"]
739
- : pct >= 50
740
- ? ["Good start! Keep practising the tricky sections. 📖", "Bon début ! Continue à pratiquer les parties difficiles. 📖"]
741
- : ["Keep going — each question makes you stronger. 🔥", "Continue — chaque question te rend plus fort. 🔥"];
742
- modalMessage.textContent = S.language === 'Français' ? msgs[1] : msgs[0];
743
-
744
- const recent = S.history.slice(-8);
745
- if (recent.length > 0) {
746
- modalHistory.classList.remove('hidden');
747
- modalHistory.innerHTML = recent.map(h => {
748
- const vc = h.verdict === 'correct' ? 'correct'
749
- : h.verdict === 'partial' ? 'partial' : 'incorrect';
750
- const vl = h.verdict === 'correct' ? '✓'
751
- : h.verdict === 'partial' ? '~' : '✗';
752
- return `<div class="modal-history-item">
753
- <span class="hist-badge ${vc}">${vl}</span>
754
- <span class="hist-q">${h.question}</span>
755
- </div>`;
756
- }).join('');
757
- }
758
-
759
- modal.classList.remove('hidden');
760
- }
761
-
762
- // ───────────────────────────────────────────────────────────────────────────
763
- // Language / difficulty toggles
764
- // ───────────────────────────────────────────────────────────────────────────
765
- function setupToggle(groupId, stateKey) {
766
- const group = $(groupId);
767
- group.querySelectorAll('.pill').forEach(btn => {
768
- btn.addEventListener('click', () => {
769
- group.querySelectorAll('.pill').forEach(b => b.classList.remove('active'));
770
- btn.classList.add('active');
771
- S[stateKey] = btn.dataset.val;
772
- });
773
- });
774
- }
775
- setupToggle('lang-toggle', 'language');
776
- setupToggle('diff-toggle', 'difficulty');
777
-
778
- // ───────────────────────────────────────────────────────────────────────────
779
- // Generate question
780
- // ───────────────────────────────────────────────────────────────────────────
781
- async function fetchQuestion() {
782
- if (!S.chunks.length) return;
783
-
784
- if (emptyState && !emptyState.classList.contains('hidden')) {
785
- emptyState.classList.add('hidden');
786
- questionArea.classList.remove('hidden');
787
- endBtn.classList.remove('hidden');
788
- }
789
-
790
- qText.textContent = '';
791
- qLoading.classList.remove('hidden');
792
- qLoadingMsg.textContent = S.total === 0
793
- ? 'Generating question (model loading, ~30s first time)…'
794
- : 'Generating question…';
795
-
796
- answerInput.value = '';
797
- charCount.textContent = '0 chars';
798
- submitBtn.disabled = true;
799
- feedbackCard.classList.add('hidden');
800
- newQBtn.disabled = true;
801
-
802
- try {
803
- const chunk = S.chunks[Math.floor(Math.random() * S.chunks.length)];
804
- S.currentChunk = chunk;
805
- const data = await apiQuestion(chunk, S.language, S.difficulty);
806
- S.currentQuestion = data.question;
807
- qText.textContent = data.question;
808
- } catch (err) {
809
- qText.textContent = '⚠ ' + err.message;
810
- } finally {
811
- qLoading.classList.add('hidden');
812
- newQBtn.disabled = false;
813
- }
814
- }
815
-
816
- startBtn.addEventListener('click', fetchQuestion);
817
- newQBtn.addEventListener('click', fetchQuestion);
818
-
819
- // ───────────────────────────────────────────────────────────────────────────
820
- // Answer input
821
- // ───────────────────────────────────────────────────────────────────────────
822
- answerInput.addEventListener('input', () => {
823
- const len = answerInput.value.trim().length;
824
- charCount.textContent = `${answerInput.value.length} chars`;
825
- submitBtn.disabled = len === 0 || !S.currentQuestion;
826
- });
827
-
828
- // ───────────────────────────────────────────────────────────────────────────
829
- // Submit answer
830
- // ───────────────────────────────────────────────────────────────────────────
831
- submitBtn.addEventListener('click', async () => {
832
- const answer = answerInput.value.trim();
833
- if (!answer || !S.currentQuestion) return;
834
-
835
- setLoadingBtn(submitBtn, submitText, submitSpinner, true, 'Evaluating…');
836
- newQBtn.disabled = true;
837
-
838
- try {
839
- const data = await apiEvaluate(
840
- S.currentQuestion, S.currentChunk, answer, S.language
841
- );
842
- S.total++;
843
- const verdict = showFeedback(data.feedback, S.currentChunk);
844
- if (verdict === 'correct') S.correct++;
845
- S.history.push({ question: S.currentQuestion, verdict });
846
- updateScore();
847
- } catch (err) {
848
- feedbackBody.innerHTML = `<span style="color:var(--red)">⚠ ${err.message}</span>`;
849
- verdictBadge.className = 'verdict-badge';
850
- verdictBadge.textContent = 'Error';
851
- feedbackCard.classList.remove('hidden');
852
- } finally {
853
- setLoadingBtn(submitBtn, submitText, submitSpinner, false, 'Submit Answer');
854
- newQBtn.disabled = false;
855
- }
856
- });
857
-
858
- // ───────────────────────────────────────────────────────────────────────────
859
- // End session
860
- // ───────────────────────────────────────────────────────────────────────────
861
- endBtn.addEventListener('click', () => {
862
- if (S.total === 0) {
863
- modal.classList.remove('hidden');
864
- } else {
865
- showModal();
866
- }
867
- });
868
-
869
- modalClose.addEventListener('click', () => {
870
- modal.classList.add('hidden');
871
- S.correct = 0; S.total = 0; S.history = [];
872
- S.currentQuestion = ''; S.currentChunk = '';
873
- updateScore();
874
- answerInput.value = '';
875
- charCount.textContent = '0 chars';
876
- submitBtn.disabled = true;
877
- feedbackCard.classList.add('hidden');
878
- emptyState.classList.remove('hidden');
879
- questionArea.classList.add('hidden');
880
- endBtn.classList.add('hidden');
881
- quizCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
882
- });
883
-
884
- modal.addEventListener('click', e => {
885
- if (e.target === modal) modal.classList.add('hidden');
886
- });
887
-
888
- // ───────────────────────────────────────────────────────────────────────────
889
- // Particles
890
- // ───────────────────────────────────────────────────────────────────────────
891
- (function initParticles() {
892
- const canvas = $('particles');
893
- const ctx = canvas.getContext('2d');
894
- let W, H, particles;
895
-
896
- function resize() {
897
- W = canvas.width = window.innerWidth;
898
- H = canvas.height = window.innerHeight;
899
- }
900
- window.addEventListener('resize', resize);
901
- resize();
902
-
903
- function rand(a, b) { return a + Math.random() * (b - a); }
904
-
905
- function makeParticle() {
906
- return {
907
- x: rand(0, W), y: rand(0, H),
908
- vx: rand(-.25, .25), vy: rand(-.25, .25),
909
- r: rand(1, 2.2),
910
- hue: Math.random() > .5 ? '#7C3AED' : '#06B6D4',
911
- alpha: rand(.15, .55),
912
- };
913
- }
914
-
915
- function init() { particles = Array.from({ length: 65 }, makeParticle); }
916
- init();
917
-
918
- function draw() {
919
- ctx.clearRect(0, 0, W, H);
920
- for (let i = 0; i < particles.length; i++) {
921
- const p = particles[i];
922
- p.x += p.vx; p.y += p.vy;
923
- if (p.x < -5) p.x = W + 5;
924
- if (p.x > W + 5) p.x = -5;
925
- if (p.y < -5) p.y = H + 5;
926
- if (p.y > H + 5) p.y = -5;
927
-
928
- for (let j = i + 1; j < particles.length; j++) {
929
- const q = particles[j];
930
- const dx = p.x - q.x, dy = p.y - q.y;
931
- const dist = Math.sqrt(dx*dx + dy*dy);
932
- if (dist < 110) {
933
- ctx.beginPath();
934
- ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
935
- const a = (1 - dist / 110) * 0.12;
936
- ctx.strokeStyle = `rgba(124,58,237,${a})`;
937
- ctx.lineWidth = 1;
938
- ctx.stroke();
939
- }
940
- }
941
-
942
- ctx.beginPath();
943
- ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
944
- const hex = p.hue === '#7C3AED' ? '124,58,237' : '6,182,212';
945
- ctx.fillStyle = `rgba(${hex},${p.alpha})`;
946
- ctx.fill();
947
- }
948
- requestAnimationFrame(draw);
949
- }
950
- draw();
951
- })();
952
- */
953
  </script>
 
53
  display: flex; flex-direction: column; gap: 20px;
54
  }
55
 
56
+ /* ── Header — amphitheater hero ───────────────────────────────────────── */
57
  .hero {
58
+ padding: 12px 0 0;
 
59
  animation: fadeUp .6s ease both;
60
  }
61
+ .amphitheater {
62
+ position: relative;
63
+ height: 380px;
64
+ perspective: 800px;
65
+ overflow: hidden;
66
+ border-radius: var(--r);
67
+ border: 1px solid var(--border);
68
+ background:
69
+ radial-gradient(ellipse 70% 45% at 50% -8%, rgba(167,139,250,.16) 0%, transparent 60%),
70
+ radial-gradient(ellipse 60% 35% at 50% 112%, rgba(6,182,212,.12) 0%, transparent 65%),
71
+ linear-gradient(180deg, #0B1126 0%, #0A0F1E 55%, #0C1226 100%);
72
+ box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 1px 0 rgba(255,255,255,.04) inset;
73
+ }
74
+
75
+ /* ceiling lights */
76
+ .amphi-light {
77
+ position: absolute; z-index: 1;
78
+ width: 4px; height: 4px; border-radius: 50%;
79
+ background: #CFE9FF;
80
+ box-shadow: 0 0 8px 2px rgba(167,139,250,.55);
81
+ animation: twinkle 3.2s ease-in-out infinite;
82
+ }
83
+ .amphi-light:nth-of-type(2n) {
84
+ box-shadow: 0 0 8px 2px rgba(103,232,249,.5);
85
+ }
86
+
87
+ /* ambient beam from above */
88
+ .amphi-beam {
89
+ position: absolute; inset: 0; z-index: 1; pointer-events: none;
90
+ background: radial-gradient(ellipse 55% 50% at 50% 0%, rgba(241,245,249,.07), transparent 70%);
91
+ }
92
+
93
+ /* curved seat rows receding into the distance */
94
+ .amphi-rows {
95
+ position: absolute; inset: 0; z-index: 1;
96
+ transform-style: preserve-3d;
97
+ }
98
+ .amphi-row {
99
+ position: absolute; left: 50%;
100
+ transform: translateX(-50%) rotateX(48deg);
101
+ transform-origin: 50% 100%;
102
+ border-radius: 50% 50% 0 0 / 90% 90% 0 0;
103
+ background:
104
+ linear-gradient(180deg, rgba(255,255,255,.09) 0%, transparent 35%),
105
+ repeating-linear-gradient(90deg,
106
+ #41322a 0,
107
+ #41322a calc(var(--seat) - 6px),
108
+ #221a15 calc(var(--seat) - 6px),
109
+ #221a15 var(--seat));
110
+ box-shadow: 0 10px 18px rgba(0,0,0,.55);
111
+ }
112
+ .amphi-row:nth-child(1) { --seat: 18px; width: 54%; height: 16px; top: 30%; opacity: .45; }
113
+ .amphi-row:nth-child(2) { --seat: 21px; width: 66%; height: 20px; top: 41%; opacity: .6; }
114
+ .amphi-row:nth-child(3) { --seat: 24px; width: 80%; height: 24px; top: 52%; opacity: .75; }
115
+ .amphi-row:nth-child(4) { --seat: 27px; width: 94%; height: 28px; top: 64%; opacity: .88; }
116
+ .amphi-row:nth-child(5) { --seat: 30px; width: 110%; height: 32px; top: 77%; opacity: 1; }
117
+
118
+ /* glowing stage + podium */
119
+ .amphi-stage {
120
+ position: absolute; bottom: -8px; left: 50%; z-index: 2;
121
+ transform: translateX(-50%);
122
+ width: 60%; height: 74px; pointer-events: none;
123
+ background: radial-gradient(ellipse at 50% 100%, rgba(6,182,212,.32), rgba(124,58,237,.16) 45%, transparent 72%);
124
+ filter: blur(2px);
125
+ }
126
+ .amphi-podium {
127
+ position: absolute; bottom: 18px; left: 50%; z-index: 2;
128
+ transform: translateX(-50%);
129
+ width: 130px; height: 26px; pointer-events: none;
130
+ border-radius: 50%;
131
+ background: radial-gradient(ellipse, rgba(103,232,249,.45), rgba(124,58,237,.22) 60%, transparent 78%);
132
+ box-shadow: 0 0 30px rgba(6,182,212,.35);
133
+ }
134
+
135
+ /* ── AI professor character ───────────────────────────────────────────── */
136
+ .professor {
137
+ position: absolute; bottom: 36px; left: 50%; z-index: 3;
138
+ width: 92px; margin-left: -46px; /* centered without transform — float animation owns transform */
139
+ cursor: pointer;
140
+ animation: prof-float 3.6s ease-in-out infinite;
141
+ filter: drop-shadow(0 10px 14px rgba(0,0,0,.5));
142
+ }
143
+ .prof-antenna {
144
+ width: 3px; height: 14px; margin: 0 auto; position: relative;
145
+ background: linear-gradient(180deg, var(--cyan-l), var(--purple));
146
+ border-radius: 2px;
147
+ }
148
+ .prof-antenna::after {
149
+ content: ''; position: absolute; top: -7px; left: 50%;
150
+ transform: translateX(-50%);
151
+ width: 9px; height: 9px; border-radius: 50%;
152
+ background: var(--cyan-l);
153
+ box-shadow: 0 0 10px 2px rgba(103,232,249,.8);
154
+ animation: pulse 2.2s ease-in-out infinite;
155
+ }
156
+ .prof-head {
157
+ width: 66px; height: 54px; margin: 0 auto; position: relative;
158
+ display: flex; align-items: center; justify-content: center; gap: 10px;
159
+ border-radius: 18px;
160
+ background: linear-gradient(145deg, #262C4F 0%, #161B36 100%);
161
+ border: 1.5px solid rgba(167,139,250,.45);
162
+ box-shadow: 0 0 18px rgba(124,58,237,.25), inset 0 1px 0 rgba(255,255,255,.08);
163
+ transition: transform .25s ease;
164
+ }
165
+ .professor:hover .prof-head { transform: rotate(-4deg); }
166
+ .prof-eye {
167
+ width: 17px; height: 19px; margin-top: -4px;
168
+ border-radius: 50%; background: #EAF2FF;
169
+ position: relative; overflow: hidden;
170
+ animation: prof-blink 5.2s infinite;
171
+ }
172
+ .prof-pupil {
173
+ position: absolute; left: 50%; top: 50%;
174
+ width: 8px; height: 8px; margin: -4px 0 0 -4px;
175
+ border-radius: 50%; background: #131A36;
176
+ transition: transform 0.1s ease;
177
+ }
178
+ .prof-pupil::after {
179
+ content: ''; position: absolute; top: 1px; left: 1.5px;
180
+ width: 2.5px; height: 2.5px; border-radius: 50%;
181
+ background: #fff; opacity: .9;
182
+ }
183
+ .prof-mouth {
184
+ position: absolute; bottom: 8px; left: 50%;
185
+ transform: translateX(-50%);
186
+ width: 16px; height: 7px;
187
+ border: 2px solid var(--cyan-l); border-top: none;
188
+ border-radius: 0 0 14px 14px;
189
+ opacity: .85; transition: width .2s, height .2s;
190
+ }
191
+ .professor:hover .prof-mouth { width: 22px; height: 10px; }
192
+ .prof-body {
193
+ width: 54px; height: 38px; margin: -3px auto 0; position: relative;
194
+ border-radius: 14px 14px 16px 16px;
195
+ background: linear-gradient(160deg, #2E2553 0%, #173A52 100%);
196
+ border: 1.5px solid rgba(103,232,249,.35);
197
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.07);
198
+ }
199
+ .prof-chest {
200
+ position: absolute; top: 50%; left: 50%;
201
+ transform: translate(-50%, -50%);
202
+ width: 12px; height: 12px; border-radius: 50%;
203
+ background: radial-gradient(circle at 35% 35%, var(--cyan-l), var(--purple));
204
+ box-shadow: 0 0 12px rgba(103,232,249,.7);
205
+ animation: pulse 2.8s ease-in-out infinite;
206
+ }
207
+ .prof-arm {
208
+ position: absolute; top: 4px;
209
+ width: 9px; height: 26px; border-radius: 6px;
210
+ background: linear-gradient(180deg, #2E2553, #1D2348);
211
+ border: 1.5px solid rgba(167,139,250,.3);
212
+ transform-origin: top center;
213
+ transition: transform .25s ease;
214
+ }
215
+ .prof-arm.left { left: -12px; transform: rotate(14deg); }
216
+ .prof-arm.right { right: -12px; transform: rotate(-14deg); }
217
+ .professor:hover .prof-arm.right { animation: prof-wave .8s ease-in-out infinite; }
218
+
219
+ /* ── Hero text overlay ────────────────────────────────────────────────── */
220
+ .hero-overlay {
221
+ position: absolute; top: 30px; left: 0; right: 0; z-index: 4;
222
+ text-align: center; pointer-events: none;
223
+ }
224
  .hero-title {
225
+ font-size: clamp(2.2rem, 6vw, 3.4rem);
226
  font-weight: 900; line-height: 1;
227
  background: linear-gradient(135deg, var(--purple-l) 0%, var(--cyan-l) 100%);
228
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
229
  background-clip: text;
230
  letter-spacing: -1.5px;
231
+ margin-bottom: 8px;
232
+ filter: drop-shadow(0 4px 18px rgba(124,58,237,.35));
233
+ animation: fadeUp .7s .15s ease both;
234
  }
235
  .hero-sub {
236
+ color: var(--muted); font-size: .98rem; font-weight: 400;
237
  letter-spacing: .01em;
238
+ animation: fadeUp .7s .35s ease both;
239
  }
240
 
241
  /* ── Card ─────────────────────────────────────────────────────────────── */
 
542
  @keyframes popIn { from { opacity: 0; transform: scale(.8); } to { opacity: 1; transform: scale(1); } }
543
  @keyframes spin { to { transform: rotate(360deg); } }
544
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
545
+ @keyframes twinkle { 0%, 100% { opacity: .9; } 50% { opacity: .25; } }
546
+ @keyframes prof-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
547
+ @keyframes prof-blink { 0%, 90%, 100% { transform: scaleY(1); } 93% { transform: scaleY(.12); } 96% { transform: scaleY(1); } }
548
+ @keyframes prof-wave {
549
+ 0%, 100% { transform: rotate(-160deg); }
550
+ 50% { transform: rotate(-115deg); }
551
+ }
552
 
553
  .hidden { display: none !important; }
554
  .fade-in-up { animation: fadeUp .4s ease both; }
 
604
  @media (max-width: 520px) {
605
  #app { padding: 20px 14px 60px; }
606
  .card { padding: 20px 18px; }
607
+ .hero { padding: 8px 0 0; }
608
+ .amphitheater { height: 300px; }
609
+ .hero-overlay { top: 20px; }
610
+ .hero-title { font-size: 2.2rem; }
611
  .controls-row { gap: 8px; }
612
  .answer-actions { flex-direction: column; }
613
  .answer-actions button { width: 100%; justify-content: center; }
 
616
  <canvas id="particles"></canvas>
617
 
618
  <div id="app">
619
+ <!-- Hero — cinematic amphitheater with AI professor -->
620
  <header class="hero">
621
+ <div class="amphitheater" id="amphitheater">
622
+ <div class="amphi-beam"></div>
623
+
624
+ <!-- ceiling lights -->
625
+ <span class="amphi-light" style="left:9%; top:9%; animation-delay:0s"></span>
626
+ <span class="amphi-light" style="left:21%; top:5%; animation-delay:.7s"></span>
627
+ <span class="amphi-light" style="left:36%; top:11%; animation-delay:1.4s"></span>
628
+ <span class="amphi-light" style="left:51%; top:4%; animation-delay:.3s"></span>
629
+ <span class="amphi-light" style="left:65%; top:10%; animation-delay:1.9s"></span>
630
+ <span class="amphi-light" style="left:79%; top:6%; animation-delay:1.1s"></span>
631
+ <span class="amphi-light" style="left:91%; top:12%; animation-delay:.5s"></span>
632
+
633
+ <!-- curved seat rows, far → near -->
634
+ <div class="amphi-rows">
635
+ <div class="amphi-row"></div>
636
+ <div class="amphi-row"></div>
637
+ <div class="amphi-row"></div>
638
+ <div class="amphi-row"></div>
639
+ <div class="amphi-row"></div>
640
+ </div>
641
+
642
+ <!-- glowing stage -->
643
+ <div class="amphi-stage"></div>
644
+ <div class="amphi-podium"></div>
645
+
646
+ <!-- AI professor (eyes wired to the mouse in BRIDGE_JS) -->
647
+ <div class="professor" id="professor" title="Your AI professor">
648
+ <div class="prof-antenna"></div>
649
+ <div class="prof-head">
650
+ <div class="prof-eye"><div class="prof-pupil"></div></div>
651
+ <div class="prof-eye"><div class="prof-pupil"></div></div>
652
+ <div class="prof-mouth"></div>
653
+ </div>
654
+ <div class="prof-body">
655
+ <div class="prof-arm left"></div>
656
+ <div class="prof-arm right"></div>
657
+ <div class="prof-chest"></div>
658
+ </div>
659
+ </div>
660
+
661
+ <!-- floating text overlay -->
662
+ <div class="hero-overlay">
663
+ <h1 class="hero-title">PaperProf</h1>
664
+ <p class="hero-sub">Upload your course. Get quizzed by AI.</p>
665
+ </div>
666
+ </div>
667
  </header>
668
 
669
  <!-- ── Quiz card ────────────────────────────────────────────────────── -->
 
799
  </div>
800
 
801
  <script>
802
+ // Intentionally emptybrowsers never execute <script> tags injected via
803
+ // innerHTML (gr.HTML), so ALL page logic (quiz flow, particles, professor
804
+ // eye tracking) lives in BRIDGE_JS in app.py, injected via demo.load(js=...).
 
 
 
805
  void 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
  </script>