Antigravity AI Agent commited on
Commit
e565aa7
·
1 Parent(s): 119acad

Optimize performance, caching, payload transfer, and complete dark mode visual redesign with Dubai Customs custom features

Browse files
app.py CHANGED
@@ -9,6 +9,7 @@ from typing import Optional
9
  from fastapi import FastAPI, HTTPException
10
  from fastapi.responses import FileResponse
11
  from fastapi.staticfiles import StaticFiles
 
12
  from pydantic import BaseModel, Field
13
 
14
  from backend.ai_definition_service import AIDefinitionService
@@ -25,8 +26,17 @@ ROOT = Path(__file__).parent
25
  DATA = ROOT / "data"
26
  logging.basicConfig(level=logging.INFO)
27
 
 
 
 
 
 
 
 
 
28
  app = FastAPI(title="FalconScan", version="1.0.0", description="CPU-first customs terminology camera assistant")
29
- app.mount("/static", StaticFiles(directory=ROOT / "frontend"), name="static")
 
30
 
31
  ocr = OCRService()
32
  Thread(target=ocr.warmup, daemon=True, name="falconscan-ocr-warmup").start()
 
9
  from fastapi import FastAPI, HTTPException
10
  from fastapi.responses import FileResponse
11
  from fastapi.staticfiles import StaticFiles
12
+ from fastapi.middleware.gzip import GZipMiddleware
13
  from pydantic import BaseModel, Field
14
 
15
  from backend.ai_definition_service import AIDefinitionService
 
26
  DATA = ROOT / "data"
27
  logging.basicConfig(level=logging.INFO)
28
 
29
+
30
+ class CacheControlledStaticFiles(StaticFiles):
31
+ def file_response(self, *args, **kwargs):
32
+ response = super().file_response(*args, **kwargs)
33
+ response.headers["Cache-Control"] = "public, max-age=86400"
34
+ return response
35
+
36
+
37
  app = FastAPI(title="FalconScan", version="1.0.0", description="CPU-first customs terminology camera assistant")
38
+ app.add_middleware(GZipMiddleware, minimum_size=1000)
39
+ app.mount("/static", CacheControlledStaticFiles(directory=ROOT / "frontend"), name="static")
40
 
41
  ocr = OCRService()
42
  Thread(target=ocr.warmup, daemon=True, name="falconscan-ocr-warmup").start()
backend/document_service.py CHANGED
@@ -172,7 +172,7 @@ class DocumentService:
172
  @staticmethod
173
  def _result(image: Image.Image, terms: list, detections: list, unknown: list, method: str) -> dict:
174
  output = io.BytesIO()
175
- image.save(output, format="JPEG", quality=80, optimize=True)
176
  return {
177
  "detected_terms": terms,
178
  "ocr_items": detections,
 
172
  @staticmethod
173
  def _result(image: Image.Image, terms: list, detections: list, unknown: list, method: str) -> dict:
174
  output = io.BytesIO()
175
+ image.save(output, format="JPEG", quality=70, optimize=True)
176
  return {
177
  "detected_terms": terms,
178
  "ocr_items": detections,
backend/glossary_service.py CHANGED
@@ -26,26 +26,57 @@ class GlossaryService:
26
  self.corrections_path = corrections_path
27
  self.approved_path = approved_path
28
  self.lock = RLock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  @staticmethod
31
  def _read(path: Path) -> dict:
32
- return json.loads(path.read_text(encoding="utf-8"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- def _index(self) -> tuple[dict, dict]:
35
- glossary = self._read(self.glossary_path)
36
- aliases: dict[str, str] = {}
37
- for canonical, entry in glossary.items():
38
- for alias in [canonical, *entry.get("aliases", [])]:
39
- aliases[normalize(alias)] = canonical
40
- return glossary, aliases
 
 
 
41
 
42
  def find_match(self, text: str, threshold: float = 0.78) -> dict | None:
43
- glossary, aliases = self._index()
44
  target = normalize(text)
45
  if not target:
46
  return None
47
  # Prefer longest contained phrases, then fuzzy matching for OCR errors.
48
- contained = [(alias, canonical) for alias, canonical in aliases.items()
49
  if alias and re.search(rf"(?<!\w){re.escape(alias)}(?!\w)", target)]
50
  match_type, score = "exact", 1.0
51
  if contained:
@@ -53,14 +84,14 @@ class GlossaryService:
53
  match_type = "exact" if alias == target else "phrase"
54
  else:
55
  alias, canonical, score = "", "", 0.0
56
- for candidate, name in aliases.items():
57
  candidate_score = SequenceMatcher(None, target, candidate).ratio()
58
  if candidate_score > score:
59
  alias, canonical, score = candidate, name, candidate_score
60
  if score < threshold:
61
  return None
62
  match_type = "fuzzy"
63
- return {"canonical": canonical, "entry": glossary[canonical],
64
  "match_type": match_type, "match_confidence": round(score, 3)}
65
 
66
  def definition(self, term: str) -> dict | None:
@@ -68,8 +99,8 @@ class GlossaryService:
68
  matched = self.find_match(term)
69
  canonical = matched["canonical"] if matched else term
70
  norm = normalize(canonical)
71
- approved = self._read(self.approved_path)
72
- corrections = self._read(self.corrections_path)
73
  # SME approval is official and intentionally overrides pending user correction.
74
  selected = next((v for k, v in approved.items() if normalize(k) == norm), None)
75
  if selected:
@@ -82,8 +113,8 @@ class GlossaryService:
82
  return self._response(canonical, item, "user_corrected", 0.9, matched)
83
  if matched:
84
  return self._response(canonical, matched["entry"],
85
- matched["entry"].get("source", "verified_glossary"),
86
- matched["match_confidence"], matched)
87
  return None
88
 
89
  @staticmethod
@@ -117,7 +148,7 @@ class GlossaryService:
117
 
118
  def match_regions(self, regions: list[dict]) -> list[dict]:
119
  """Find every known canonical term inside positioned text regions."""
120
- _, aliases = self._index()
121
  found: list[dict] = []
122
  seen: set[tuple[str, tuple[int, ...]]] = set()
123
  for region in regions:
 
26
  self.corrections_path = corrections_path
27
  self.approved_path = approved_path
28
  self.lock = RLock()
29
+ self._glossary_cache = None
30
+ self._aliases_cache = None
31
+ self._corrections_cache = None
32
+ self._corrections_mtime = 0.0
33
+ self._approved_cache = None
34
+ self._approved_mtime = 0.0
35
+ self._init_glossary()
36
+
37
+ def _init_glossary(self):
38
+ with self.lock:
39
+ self._glossary_cache = self._read(self.glossary_path)
40
+ self._aliases_cache = {}
41
+ for canonical, entry in self._glossary_cache.items():
42
+ for alias in [canonical, *entry.get("aliases", [])]:
43
+ self._aliases_cache[normalize(alias)] = canonical
44
 
45
  @staticmethod
46
  def _read(path: Path) -> dict:
47
+ try:
48
+ return json.loads(path.read_text(encoding="utf-8"))
49
+ except FileNotFoundError:
50
+ return {}
51
+
52
+ def _get_corrections(self) -> dict:
53
+ try:
54
+ mtime = self.corrections_path.stat().st_mtime
55
+ except FileNotFoundError:
56
+ mtime = 0.0
57
+ if self._corrections_cache is None or mtime > self._corrections_mtime:
58
+ with self.lock:
59
+ self._corrections_cache = self._read(self.corrections_path)
60
+ self._corrections_mtime = mtime
61
+ return self._corrections_cache
62
 
63
+ def _get_approved(self) -> dict:
64
+ try:
65
+ mtime = self.approved_path.stat().st_mtime
66
+ except FileNotFoundError:
67
+ mtime = 0.0
68
+ if self._approved_cache is None or mtime > self._approved_mtime:
69
+ with self.lock:
70
+ self._approved_cache = self._read(self.approved_path)
71
+ self._approved_mtime = mtime
72
+ return self._approved_cache
73
 
74
  def find_match(self, text: str, threshold: float = 0.78) -> dict | None:
 
75
  target = normalize(text)
76
  if not target:
77
  return None
78
  # Prefer longest contained phrases, then fuzzy matching for OCR errors.
79
+ contained = [(alias, canonical) for alias, canonical in self._aliases_cache.items()
80
  if alias and re.search(rf"(?<!\w){re.escape(alias)}(?!\w)", target)]
81
  match_type, score = "exact", 1.0
82
  if contained:
 
84
  match_type = "exact" if alias == target else "phrase"
85
  else:
86
  alias, canonical, score = "", "", 0.0
87
+ for candidate, name in self._aliases_cache.items():
88
  candidate_score = SequenceMatcher(None, target, candidate).ratio()
89
  if candidate_score > score:
90
  alias, canonical, score = candidate, name, candidate_score
91
  if score < threshold:
92
  return None
93
  match_type = "fuzzy"
94
+ return {"canonical": canonical, "entry": self._glossary_cache[canonical],
95
  "match_type": match_type, "match_confidence": round(score, 3)}
96
 
97
  def definition(self, term: str) -> dict | None:
 
99
  matched = self.find_match(term)
100
  canonical = matched["canonical"] if matched else term
101
  norm = normalize(canonical)
102
+ approved = self._get_approved()
103
+ corrections = self._get_corrections()
104
  # SME approval is official and intentionally overrides pending user correction.
105
  selected = next((v for k, v in approved.items() if normalize(k) == norm), None)
106
  if selected:
 
113
  return self._response(canonical, item, "user_corrected", 0.9, matched)
114
  if matched:
115
  return self._response(canonical, matched["entry"],
116
+ matched["entry"].get("source", "verified_glossary"),
117
+ matched["match_confidence"], matched)
118
  return None
119
 
120
  @staticmethod
 
148
 
149
  def match_regions(self, regions: list[dict]) -> list[dict]:
150
  """Find every known canonical term inside positioned text regions."""
151
+ aliases = self._aliases_cache
152
  found: list[dict] = []
153
  seen: set[tuple[str, tuple[int, ...]]] = set()
154
  for region in regions:
backend/progress_service.py CHANGED
@@ -8,15 +8,32 @@ class ProgressService:
8
  def __init__(self, path: Path):
9
  self.path = path
10
  self.lock = RLock()
 
 
11
 
12
  def get(self) -> dict:
13
- with self.lock:
14
- return json.loads(self.path.read_text(encoding="utf-8"))
 
 
 
 
 
 
 
 
 
 
15
 
16
  def update(self, **changes) -> dict:
17
  with self.lock:
18
- data = self.get()
19
  data.update(changes)
20
  data["last_updated"] = datetime.now(timezone.utc).isoformat()
21
  self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
 
 
 
 
 
22
  return data
 
8
  def __init__(self, path: Path):
9
  self.path = path
10
  self.lock = RLock()
11
+ self._cache = None
12
+ self._mtime = 0.0
13
 
14
  def get(self) -> dict:
15
+ try:
16
+ mtime = self.path.stat().st_mtime
17
+ except FileNotFoundError:
18
+ mtime = 0.0
19
+ if self._cache is None or mtime > self._mtime:
20
+ with self.lock:
21
+ try:
22
+ self._cache = json.loads(self.path.read_text(encoding="utf-8"))
23
+ except FileNotFoundError:
24
+ self._cache = {}
25
+ self._mtime = mtime
26
+ return self._cache
27
 
28
  def update(self, **changes) -> dict:
29
  with self.lock:
30
+ data = dict(self.get())
31
  data.update(changes)
32
  data["last_updated"] = datetime.now(timezone.utc).isoformat()
33
  self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
34
+ self._cache = data
35
+ try:
36
+ self._mtime = self.path.stat().st_mtime
37
+ except FileNotFoundError:
38
+ self._mtime = 0.0
39
  return data
frontend/camera.js CHANGED
@@ -113,7 +113,7 @@
113
  setStatus("Finding document terms", source === "upload" ? "Reading the uploaded page securely." : "Reading one clear camera frame.");
114
  showProgress("Recognizing text and matching business terminology…");
115
  const payload = {
116
- image_base64: canvas.toDataURL("image/jpeg", 0.86),
117
  frame_width: canvas.width,
118
  frame_height: canvas.height,
119
  language_preference: $("#language").value,
@@ -227,7 +227,7 @@
227
  canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
228
  canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
229
  canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
230
- const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.80));
231
  return { base64: await readBase64(blob), filename: "optimized-upload.jpg" };
232
  } finally {
233
  URL.revokeObjectURL(objectUrl);
 
113
  setStatus("Finding document terms", source === "upload" ? "Reading the uploaded page securely." : "Reading one clear camera frame.");
114
  showProgress("Recognizing text and matching business terminology…");
115
  const payload = {
116
+ image_base64: canvas.toDataURL("image/jpeg", 0.75),
117
  frame_width: canvas.width,
118
  frame_height: canvas.height,
119
  language_preference: $("#language").value,
 
227
  canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
228
  canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
229
  canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
230
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.70));
231
  return { base64: await readBase64(blob), filename: "optimized-upload.jpg" };
232
  } finally {
233
  URL.revokeObjectURL(objectUrl);
frontend/feedback.js CHANGED
@@ -1 +1 @@
1
- window.FalconFeedback=(()=>{let current=null;const $=s=>document.querySelector(s);function init(){document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>close(b.dataset.close));$('#thumbUp').onclick=()=>submit('thumbs_up');$('#thumbDown').onclick=()=>{$('#correctionForm').hidden=false;$('#correctionText').focus()};$('#correctionForm').onsubmit=e=>{e.preventDefault();submit('thumbs_down',$('#correctionText').value)};$('#adminButton').onclick=openAdmin;loadCount()}function open(item){current=item;const lang=$('#language').value;const arabic=lang==='ar'&&item.definition_ar;$('#definitionModal').classList.add('open');$('#definitionModal').setAttribute('aria-hidden','false');$('.definition-card').dir=arabic?'rtl':'ltr';$('#definitionCategory').textContent=item.category||'CUSTOMS TERM';$('#definitionTerm').textContent=item.term;$('#definitionFullForm').textContent=item.full_form||'';$('#definitionText').textContent=arabic?item.definition_ar:item.definition;$('#definitionSource').textContent=item.source_label;$('#definitionConfidence').textContent=`${Math.round(item.confidence*100)}% confidence`;$('#relatedTerms').textContent=item.related_terms?.length?`Related: ${item.related_terms.join(' · ')}`:'';$('#correctionForm').hidden=true;$('#correctionText').value='';$('#feedbackMessage').textContent=''}function close(id){$('#'+id).classList.remove('open');$('#'+id).setAttribute('aria-hidden','true')}async function submit(type,correction=null){if(!current)return;const response=await fetch('/submit-feedback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term:current.term,old_definition:current.definition,corrected_definition:correction,feedback_type:type})});const data=await response.json();$('#feedbackMessage').textContent=response.ok?data.message:(data.detail||'Could not save feedback');if(response.ok&&correction){current.definition=correction;current.source='user_corrected';current.source_label='User correction (pending SME review)';loadCount();setTimeout(()=>close('definitionModal'),1000)}}async function loadCount(){const badge=$('#reviewCount'),button=$('#adminButton');try{const data=await fetch('/admin/corrections').then(r=>{if(!r.ok)throw new Error('Review queue unavailable');return r.json()});const count=Array.isArray(data.items)?data.items.length:0;badge.textContent=String(count);badge.hidden=count===0;button.setAttribute('aria-label',count?`Open SME review queue, ${count} pending ${count===1?'correction':'corrections'}`:'Open SME review queue')}catch{badge.hidden=true;button.setAttribute('aria-label','Open SME review queue; count unavailable')}}async function openAdmin(){$('#adminModal').classList.add('open');const list=$('#adminList');list.innerHTML='<p class="muted">Loading…</p>';const data=await fetch('/admin/corrections').then(r=>r.json());list.innerHTML=data.items.length?data.items.map(item=>`<article class="admin-item"><h3>${escapeHtml(item.term)}</h3><p class="muted">Current: ${escapeHtml(item.old_definition||item.previous_definition||'—')}</p><p><strong>Suggestion:</strong> ${escapeHtml(item.corrected_definition)}</p><small>Suggested by ${escapeHtml(item.suggested_by)} · ${new Date(item.created_at).toLocaleString()}</small><div class="admin-actions"><button class="approve" data-review="approve" data-term="${escapeHtml(item.term)}">Approve</button><button class="reject" data-review="reject" data-term="${escapeHtml(item.term)}">Reject</button></div></article>`).join(''):'<p class="muted">No corrections are waiting for review.</p>';list.querySelectorAll('[data-review]').forEach(b=>b.onclick=()=>review(b.dataset.term,b.dataset.review))}async function review(term,action){await fetch('/admin/review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term,action,reviewer:'FalconScan SME'})});openAdmin();loadCount()}function escapeHtml(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}return{init,open}})();
 
1
+ window.FalconFeedback=(()=>{let current=null;const $=s=>document.querySelector(s);function init(){document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>close(b.dataset.close));$('#thumbUp').onclick=()=>submit('thumbs_up');$('#thumbDown').onclick=()=>{$('#correctionForm').hidden=false;$('#correctionText').focus()};$('#correctionForm').onsubmit=e=>{e.preventDefault();submit('thumbs_down',$('#correctionText').value)};$('#adminButton').onclick=openAdmin;loadCount()}function open(item){current=item;const lang=$('#language').value;const arabic=lang==='ar'&&item.definition_ar;$('#definitionModal').classList.add('open');$('#definitionModal').setAttribute('aria-hidden','false');$('.definition-card').dir=arabic?'rtl':'ltr';$('#definitionCategory').textContent=item.category||'CUSTOMS TERM';$('#definitionTerm').textContent=item.term;$('#definitionFullForm').textContent=item.full_form||'';$('#definitionText').textContent=arabic?item.definition_ar:item.definition;$('#definitionSource').textContent=item.source_label;const confPercent=Math.round(item.confidence*100);$('#definitionConfidence').textContent=`${confPercent}% confidence`;$('#definitionConfidence').className='confidence-score '+(confPercent>=90?'conf-good':confPercent>=70?'conf-neutral':'conf-bad');$('#relatedTerms').textContent=item.related_terms?.length?`Related: ${item.related_terms.join(' · ')}`:'';$('#correctionForm').hidden=true;$('#correctionText').value='';$('#feedbackMessage').textContent=''}function close(id){$('#'+id).classList.remove('open');$('#'+id).setAttribute('aria-hidden','true')}async function submit(type,correction=null){if(!current)return;const response=await fetch('/submit-feedback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term:current.term,old_definition:current.definition,corrected_definition:correction,feedback_type:type})});const data=await response.json();$('#feedbackMessage').textContent=response.ok?data.message:(data.detail||'Could not save feedback');if(response.ok&&correction){current.definition=correction;current.source='user_corrected';current.source_label='User correction (pending SME review)';loadCount();setTimeout(()=>close('definitionModal'),1000)}}async function loadCount(){const badge=$('#reviewCount'),button=$('#adminButton');try{const data=await fetch('/admin/corrections').then(r=>{if(!r.ok)throw new Error('Review queue unavailable');return r.json()});const count=Array.isArray(data.items)?data.items.length:0;badge.textContent=String(count);badge.hidden=count===0;button.setAttribute('aria-label',count?`Open SME review queue, ${count} pending ${count===1?'correction':'corrections'}`:'Open SME review queue')}catch{badge.hidden=true;button.setAttribute('aria-label','Open SME review queue; count unavailable')}}async function openAdmin(){$('#adminModal').classList.add('open');const list=$('#adminList');list.innerHTML='<p class="muted">Loading…</p>';const data=await fetch('/admin/corrections').then(r=>r.json());list.innerHTML=data.items.length?data.items.map(item=>`<article class="admin-item"><h3>${escapeHtml(item.term)}</h3><p class="muted">Current: ${escapeHtml(item.old_definition||item.previous_definition||'—')}</p><p><strong>Suggestion:</strong> ${escapeHtml(item.corrected_definition)}</p><small>Suggested by ${escapeHtml(item.suggested_by)} · ${new Date(item.created_at).toLocaleString()}</small><div class="admin-actions"><button class="approve" data-review="approve" data-term="${escapeHtml(item.term)}">Approve</button><button class="reject" data-review="reject" data-term="${escapeHtml(item.term)}">Reject</button></div></article>`).join(''):'<p class="muted">No corrections are waiting for review.</p>';list.querySelectorAll('[data-review]').forEach(b=>b.onclick=()=>review(b.dataset.term,b.dataset.review))}async function review(term,action){await fetch('/admin/review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({term,action,reviewer:'FalconScan SME'})});openAdmin();loadCount()}function escapeHtml(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]))}return{init,open}})();
frontend/index.html CHANGED
@@ -19,7 +19,7 @@
19
  <main>
20
  <section class="intro">
21
  <div><p class="eyebrow">LIVE DOCUMENT ASSISTANT</p><h1>Point. Pause. Understand.</h1>
22
- <p>FalconScan finds customs and freight terminology on physical documents, then explains it without taking you away from the camera.</p></div>
23
  </section>
24
 
25
  <aside class="trust-note" aria-label="Privacy note">
@@ -64,11 +64,11 @@
64
  <section class="trust-row"><div><b>EN + AR</b><span>Bilingual OCR</span></div><div><b>LOCAL FIRST</b><span>Glossary lookup</span></div><div><b>HUMAN LED</b><span>SME-approved knowledge</span></div><div><b>CPU READY</b><span>Built for free Spaces</span></div></section>
65
  </main>
66
 
67
- <div id="definitionModal" class="modal" aria-hidden="true"><div class="definition-card" role="dialog" aria-modal="true"><button class="close" data-close="definitionModal">×</button><p class="eyebrow" id="definitionCategory">CUSTOMS TERM</p><h2 id="definitionTerm"></h2><p id="definitionFullForm" class="full-form"></p><p id="definitionText" class="definition-text"></p><div class="definition-meta"><span id="definitionSource"></span><span id="definitionConfidence"></span></div><div class="related" id="relatedTerms"></div><div class="feedback-row"><span>Was this useful?</span><button id="thumbUp" aria-label="Helpful"></button><button id="thumbDown" aria-label="Incorrect"></button></div><form id="correctionForm" hidden><label>Suggest a clearer, correct definition<textarea id="correctionText" required maxlength="2000"></textarea></label><button class="primary" type="submit">Save for review</button></form><p id="feedbackMessage" class="feedback-message"></p></div></div>
68
 
69
  <div id="adminModal" class="modal" aria-hidden="true"><div class="admin-card" role="dialog" aria-modal="true"><button class="close" data-close="adminModal">×</button><p class="eyebrow">KNOWLEDGE GOVERNANCE</p><h2>SME correction review</h2><p class="muted">Approve suggestions to make them the official definition.</p><div id="adminList" class="admin-list"></div></div></div>
70
  <div id="insightModal" class="modal" aria-hidden="true"><div class="insight-card" role="dialog" aria-modal="true" aria-labelledby="insightTitle"><button id="insightClose" class="close" aria-label="Close insight">×</button><p class="eyebrow">LIVE DOCUMENT INSIGHT</p><h2 id="insightTitle">Selection insight</h2><blockquote id="insightSelection"></blockquote><section><small>SUMMARY</small><p id="insightSummary"></p></section><section><small>BUSINESS MEANING</small><p id="insightBusiness"></p></section><div id="insightTerms" class="insight-terms"></div><div class="definition-meta"><span id="insightSource"></span><span id="insightConfidence"></span></div></div></div>
71
  <div id="toast" class="toast"></div>
72
- <script src="/static/insights.js"></script><script src="/static/overlay.js"></script><script src="/static/feedback.js"></script><script src="/static/camera.js"></script>
73
  </body>
74
  </html>
 
19
  <main>
20
  <section class="intro">
21
  <div><p class="eyebrow">LIVE DOCUMENT ASSISTANT</p><h1>Point. Pause. Understand.</h1>
22
+ <p>FalconScan is a premium customs intelligence assistant designed to empower Dubai Customs officers in their daily operations. By scanning and explaining complex freight and clearance terminology directly on physical documents, it simplifies workflows, enhances decision confidence, and makes daily customs tasks seamless and efficient.</p></div>
23
  </section>
24
 
25
  <aside class="trust-note" aria-label="Privacy note">
 
64
  <section class="trust-row"><div><b>EN + AR</b><span>Bilingual OCR</span></div><div><b>LOCAL FIRST</b><span>Glossary lookup</span></div><div><b>HUMAN LED</b><span>SME-approved knowledge</span></div><div><b>CPU READY</b><span>Built for free Spaces</span></div></section>
65
  </main>
66
 
67
+ <div id="definitionModal" class="modal" aria-hidden="true"><div class="definition-card" role="dialog" aria-modal="true"><button class="close" data-close="definitionModal">×</button><p class="eyebrow" id="definitionCategory">CUSTOMS TERM</p><h2 id="definitionTerm"></h2><p id="definitionFullForm" class="full-form"></p><p id="definitionText" class="definition-text"></p><div class="definition-meta"><span id="definitionSource"></span><span id="definitionConfidence"></span></div><div class="related" id="relatedTerms"></div><div class="feedback-row"><span>Was this useful?</span><button id="thumbUp" aria-label="Helpful">👍</button><button id="thumbDown" aria-label="Incorrect">👎</button></div><form id="correctionForm" hidden><label>Suggest a clearer, correct definition<textarea id="correctionText" required maxlength="2000"></textarea></label><button class="primary" type="submit">Save for review</button></form><p id="feedbackMessage" class="feedback-message"></p></div></div>
68
 
69
  <div id="adminModal" class="modal" aria-hidden="true"><div class="admin-card" role="dialog" aria-modal="true"><button class="close" data-close="adminModal">×</button><p class="eyebrow">KNOWLEDGE GOVERNANCE</p><h2>SME correction review</h2><p class="muted">Approve suggestions to make them the official definition.</p><div id="adminList" class="admin-list"></div></div></div>
70
  <div id="insightModal" class="modal" aria-hidden="true"><div class="insight-card" role="dialog" aria-modal="true" aria-labelledby="insightTitle"><button id="insightClose" class="close" aria-label="Close insight">×</button><p class="eyebrow">LIVE DOCUMENT INSIGHT</p><h2 id="insightTitle">Selection insight</h2><blockquote id="insightSelection"></blockquote><section><small>SUMMARY</small><p id="insightSummary"></p></section><section><small>BUSINESS MEANING</small><p id="insightBusiness"></p></section><div id="insightTerms" class="insight-terms"></div><div class="definition-meta"><span id="insightSource"></span><span id="insightConfidence"></span></div></div></div>
71
  <div id="toast" class="toast"></div>
72
+ <script src="/static/insights.js" defer></script><script src="/static/overlay.js" defer></script><script src="/static/feedback.js" defer></script><script src="/static/camera.js" defer></script>
73
  </body>
74
  </html>
frontend/insights.js CHANGED
@@ -25,7 +25,10 @@ window.FalconInsights = (() => {
25
  $("#insightSummary").textContent = data.summary;
26
  $("#insightBusiness").textContent = data.business_meaning;
27
  $("#insightSource").textContent = data.source_label;
28
- $("#insightConfidence").textContent = `${Math.round(data.confidence * 100)}% confidence`;
 
 
 
29
  $("#insightTerms").innerHTML = (data.recognized_terms || []).map((item) => `<button type="button" data-term="${escapeHtml(item.term)}">${escapeHtml(item.term)}</button>`).join("");
30
  $("#insightTerms").querySelectorAll("button").forEach((button) => {
31
  button.onclick = () => {
 
25
  $("#insightSummary").textContent = data.summary;
26
  $("#insightBusiness").textContent = data.business_meaning;
27
  $("#insightSource").textContent = data.source_label;
28
+ const confPercent = Math.round(data.confidence * 100);
29
+ const confEl = $("#insightConfidence");
30
+ confEl.textContent = `${confPercent}% confidence`;
31
+ confEl.className = 'confidence-score ' + (confPercent >= 90 ? 'conf-good' : confPercent >= 70 ? 'conf-neutral' : 'conf-bad');
32
  $("#insightTerms").innerHTML = (data.recognized_terms || []).map((item) => `<button type="button" data-term="${escapeHtml(item.term)}">${escapeHtml(item.term)}</button>`).join("");
33
  $("#insightTerms").querySelectorAll("button").forEach((button) => {
34
  button.onclick = () => {
frontend/overlay.js CHANGED
@@ -6,6 +6,7 @@ window.FalconOverlay = (() => {
6
  let uploadedPreview;
7
  let documentScroller;
8
  let activePopover = null;
 
9
 
10
  function init() {
11
  viewport = document.querySelector("#viewport");
@@ -38,6 +39,7 @@ window.FalconOverlay = (() => {
38
  terms = items;
39
  overlay.innerHTML = "";
40
  activePopover = null;
 
41
  const layout = geometry(frameWidth, frameHeight);
42
  renderSelectionLayer(textRegions, layout);
43
  renderMarkers(items, layout);
@@ -46,44 +48,49 @@ window.FalconOverlay = (() => {
46
 
47
  function renderMarkers(items, layout) {
48
  if (!items.length) return;
49
- const visible = [...items].sort((a, b) => b.confidence - a.confidence).slice(0, 3);
50
- const occupied = [];
51
-
52
- visible.forEach((item, index) => {
53
- const [x1, y1, x2, y2] = item.bbox;
54
- let left = layout.offsetX + x2 * layout.scale + 7;
55
- let top = layout.offsetY + ((y1 + y2) / 2) * layout.scale - 12;
56
- if (left > layout.viewRect.width - 34) left = layout.offsetX + x1 * layout.scale - 31;
57
- left = clamp(left, 7, layout.viewRect.width - 31);
58
- top = clamp(top, 46, layout.viewRect.height - 38);
59
- let attempts = 0;
60
- while (occupied.some((point) => Math.hypot(point.left - left, point.top - top) < 31) && attempts < 12) {
61
- top = clamp(top + 32, 46, layout.viewRect.height - 38);
62
- if (top >= layout.viewRect.height - 39) left = clamp(left - 34, 7, layout.viewRect.width - 31);
63
- attempts += 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
- occupied.push({ left, top });
66
-
67
- const marker = document.createElement("button");
68
- marker.className = "term-marker";
69
- marker.type = "button";
70
- marker.style.left = `${left}px`;
71
- marker.style.top = `${top}px`;
72
- marker.style.setProperty("--marker-delay", `${Math.min(index * 35, 350)}ms`);
73
- marker.setAttribute("aria-label", `Explain ${item.term}`);
74
- marker.innerHTML = `<span></span>`;
75
- let dragged = false;
76
- enableDrag(marker, layout, () => { dragged = true; });
77
- marker.onclick = (event) => {
78
- event.stopPropagation();
79
- if (dragged) {
80
- dragged = false;
81
- return;
82
- }
83
- openPopover(item, marker, layout);
84
- };
85
- overlay.appendChild(marker);
86
- });
87
  }
88
 
89
  function enableDrag(marker, layout, onDrag) {
@@ -172,7 +179,9 @@ window.FalconOverlay = (() => {
172
  popover.style.left = `${left}px`;
173
  popover.style.top = `${top}px`;
174
  popover.style.width = `${width}px`;
175
- popover.innerHTML = `<div class="popover-heading"><span class="callout-dot"></span><span class="callout-line"></span><strong>${escapeHtml(item.term)}</strong><small>${Math.round(item.confidence * 100)}%</small></div><p>${escapeHtml(item.definition)}</p><button type="button">Open full meaning</button>`;
 
 
176
  popover.querySelector("button").onclick = (event) => {
177
  event.stopPropagation();
178
  window.FalconFeedback.open(item);
 
6
  let uploadedPreview;
7
  let documentScroller;
8
  let activePopover = null;
9
+ let currentDotIndex = 0;
10
 
11
  function init() {
12
  viewport = document.querySelector("#viewport");
 
39
  terms = items;
40
  overlay.innerHTML = "";
41
  activePopover = null;
42
+ currentDotIndex = 0;
43
  const layout = geometry(frameWidth, frameHeight);
44
  renderSelectionLayer(textRegions, layout);
45
  renderMarkers(items, layout);
 
48
 
49
  function renderMarkers(items, layout) {
50
  if (!items.length) return;
51
+ const sorted = [...items].sort((a, b) => b.confidence - a.confidence);
52
+ renderSingleMarker(sorted, layout);
53
+ }
54
+
55
+ function renderSingleMarker(sortedItems, layout) {
56
+ // Remove existing non-user markers
57
+ overlay.querySelectorAll(".term-marker:not(.user-triggered)").forEach(m => m.remove());
58
+ if (!sortedItems.length) return;
59
+
60
+ const item = sortedItems[currentDotIndex % sortedItems.length];
61
+ const [x1, y1, x2, y2] = item.bbox;
62
+ let left = layout.offsetX + x2 * layout.scale + 7;
63
+ let top = layout.offsetY + ((y1 + y2) / 2) * layout.scale - 12;
64
+ if (left > layout.viewRect.width - 34) left = layout.offsetX + x1 * layout.scale - 31;
65
+ left = clamp(left, 7, layout.viewRect.width - 31);
66
+ top = clamp(top, 46, layout.viewRect.height - 38);
67
+
68
+ const marker = document.createElement("button");
69
+ marker.className = "term-marker";
70
+ marker.type = "button";
71
+ marker.style.left = `${left}px`;
72
+ marker.style.top = `${top}px`;
73
+ marker.setAttribute("aria-label", `Explain ${item.term}`);
74
+ marker.innerHTML = `<span></span>`;
75
+
76
+ let dragged = false;
77
+ enableDrag(marker, layout, () => { dragged = true; });
78
+ marker.onclick = (event) => {
79
+ event.stopPropagation();
80
+ if (dragged) {
81
+ dragged = false;
82
+ return;
83
  }
84
+ openPopover(item, marker, layout);
85
+ if (sortedItems.length > 1) {
86
+ currentDotIndex = (currentDotIndex + 1) % sortedItems.length;
87
+ setTimeout(() => {
88
+ marker.remove();
89
+ renderSingleMarker(sortedItems, layout);
90
+ }, 150);
91
+ }
92
+ };
93
+ overlay.appendChild(marker);
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
 
96
  function enableDrag(marker, layout, onDrag) {
 
179
  popover.style.left = `${left}px`;
180
  popover.style.top = `${top}px`;
181
  popover.style.width = `${width}px`;
182
+ const confPercent = Math.round(item.confidence * 100);
183
+ const confClass = confPercent >= 90 ? 'conf-good' : confPercent >= 70 ? 'conf-neutral' : 'conf-bad';
184
+ popover.innerHTML = `<div class="popover-heading"><span class="callout-dot"></span><span class="callout-line"></span><strong>${escapeHtml(item.term)}</strong><small class="${confClass}">${confPercent}%</small></div><p>${escapeHtml(item.definition)}</p><button type="button">Open full meaning</button>`;
185
  popover.querySelector("button").onclick = (event) => {
186
  event.stopPropagation();
187
  window.FalconFeedback.open(item);
frontend/styles.css CHANGED
@@ -1,189 +1,440 @@
1
  :root {
2
- --ink: #14201d;
3
- --muted: #64706c;
4
- --paper: #f5f5f2;
5
- --white: #fff;
6
- --green: #087a5b;
7
- --lime: #c9f257;
8
- --line: #dfe3df;
9
- --dark: #081c18;
10
- --glass: rgba(255, 255, 255, .78);
11
- --shadow: 0 22px 70px rgba(20, 38, 33, .12);
12
  --safe-bottom: env(safe-area-inset-bottom, 0px);
 
13
  }
14
 
15
  * { box-sizing: border-box; }
16
  html { scroll-behavior: smooth; }
17
- body { margin: 0; min-width: 320px; background: radial-gradient(circle at 50% -10%, #fff 0, var(--paper) 48%); color: var(--ink); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
 
 
 
 
 
 
 
18
  button, select, textarea { font: inherit; }
19
  button { cursor: pointer; }
20
 
21
- /* Mobile is the base layout. Larger screens progressively enhance it. */
 
 
 
 
 
 
22
  header {
23
  position: sticky; top: 0; z-index: 10;
24
  min-height: 64px; padding: 10px 14px;
25
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
26
- border-bottom: 1px solid rgba(20, 35, 31, .08); background: rgba(247, 247, 244, .82);
 
27
  backdrop-filter: saturate(180%) blur(20px);
28
  }
29
  .brand { display: flex; align-items: center; gap: 9px; min-width: 0; text-decoration: none; color: var(--ink); font-weight: 800; font-size: 16px; }
30
  .brand small { display: none; font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); margin-top: 2px; }
31
- .mark { position: relative; display: block; flex: 0 0 auto; width: 38px; height: 38px; overflow: hidden; background: var(--dark); border-radius: 11px; box-shadow: 0 7px 18px rgba(8,28,24,.18); isolation: isolate; }
32
- .mark::before { content: ""; position: absolute; z-index: 0; width: 30px; height: 8px; left: 5px; top: 15px; background: linear-gradient(90deg,var(--lime),#79cfa9); border-radius: 99px; transform: rotate(-43deg); opacity: .38; }
33
- .mark-f,.mark-s { position: absolute; z-index: 1; top: 5px; font-family: -apple-system,BlinkMacSystemFont,"SF Pro Display",Inter,sans-serif; font-size: 24px; font-weight: 850; line-height: 28px; letter-spacing: -.12em; }
 
 
 
 
 
 
 
 
 
 
 
34
  .mark-f { left: 7px; color: var(--lime); }
35
  .mark-s { left: 17px; color: #fff; mix-blend-mode: screen; }
 
36
  .header-actions { display: flex; align-items: center; gap: 6px; }
37
- .header-actions select, .ghost { min-height: 38px; border: 1px solid rgba(20, 35, 31, .1); background: rgba(255,255,255,.64); border-radius: 11px; padding: 7px 9px; color: var(--ink); }
 
 
 
 
 
 
 
 
 
 
38
  .ghost { width: 42px; overflow: hidden; white-space: nowrap; font-size: 0; }
39
  .ghost::before { content: "SME"; font-size: 10px; font-weight: 800; }
40
- .count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 2px; padding: 0 5px; background: var(--lime); border-radius: 99px; font-size: 10px; color: var(--dark); }
41
  .count[hidden] { display: none; }
42
 
43
  main { width: 100%; max-width: 1320px; margin: auto; padding: 30px 14px calc(64px + var(--safe-bottom)); }
44
  .intro { margin-bottom: 18px; }
45
- .intro h1 { max-width: 10ch; margin: 2px 0 12px; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-weight: 680; font-size: clamp(39px, 12vw, 64px); line-height: .98; letter-spacing: -.055em; }
 
 
 
 
 
 
 
 
 
46
  .intro > div > p:last-child { max-width: 680px; margin: 0; color: var(--muted); line-height: 1.55; font-size: 14px; }
47
- .eyebrow { margin: 0 0 8px; color: var(--green); font-size: 10px; letter-spacing: .18em; font-weight: 800; }
48
- .trust-note { display: grid; grid-template-columns: 34px 1fr; gap: 11px; align-items: center; margin: 18px 0; padding: 13px 14px; border: 1px solid rgba(8,122,91,.12); background: rgba(232,247,240,.66); border-radius: 15px; }
49
- .trust-icon { display: grid; place-items: center; width: 34px; height: 34px; background: #d7f4e8; color: var(--green); border-radius: 50%; font-weight: 800; }
50
- .trust-note strong { display: block; font-size: 13px; }
51
- .trust-note p { margin: 2px 0 0; color: #53645f; font-size: 11px; line-height: 1.45; }
52
- .trust-label { display: none; color: var(--green); font-size: 9px; font-weight: 800; letter-spacing: .13em; }
53
-
54
- .scanner-shell { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: var(--white); border: 1px solid rgba(20,35,31,.06); border-radius: 20px; box-shadow: var(--shadow); transition: grid-template-columns .42s cubic-bezier(.22,1,.36,1); }
55
- .viewport { position: relative; min-height: min(62svh, 560px); background: radial-gradient(circle at center, #23423a, #071a18 72%); overflow: hidden; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  .viewport video { position: absolute; width: 100%; height: 100%; object-fit: contain; }
57
- .document-scroller { position: absolute; inset: 0; z-index: 1; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-color: #71827c #162a25; scrollbar-width: auto; background: #152622; -webkit-overflow-scrolling: touch; touch-action: pan-x pan-y; }
 
 
 
 
 
 
58
  .document-scroller[hidden] { display: none; }
59
  .document-scroller::-webkit-scrollbar { width: 11px; height: 11px; }
60
- .document-scroller::-webkit-scrollbar-track { background: #162a25; }
61
- .document-scroller::-webkit-scrollbar-thumb { border: 3px solid #162a25; background: #71827c; border-radius: 99px; }
62
  .document-stage { position: relative; width: 100%; min-height: 100%; background: #fff; }
63
  .uploaded-preview { display: block; width: 100%; height: auto; min-height: 100%; object-fit: contain; object-position: top center; background: #fff; }
64
  .document-stage > .overlay { position: absolute; inset: 0; width: 100%; height: 100%; }
65
  .overlay { position: absolute; inset: 0; pointer-events: none; }
66
- .term-marker { position: absolute; z-index: 4; display: grid; place-items: center; width: 18px; height: 18px; border: 1.5px solid rgba(255,255,255,.9); padding: 0; background: rgba(8,28,24,.9); border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,.26); pointer-events: auto; cursor: grab; touch-action: none; animation: marker-in .35s var(--marker-delay, 0ms) both cubic-bezier(.22,1,.36,1); }
 
 
 
 
 
 
 
 
67
  .term-marker:active { cursor: grabbing; }
68
- .term-marker span { width: 6px; height: 6px; background: var(--lime); border-radius: 50%; box-shadow: 0 0 0 3px rgba(201,242,87,.16); }
69
- .term-marker:hover, .term-marker:focus-visible, .term-marker.active { border-color: var(--lime); transform: scale(1.1); }
 
 
 
 
 
 
 
 
70
  @keyframes marker-in { from { opacity: 0; transform: scale(.4); } }
71
- .term-popover { position: absolute; z-index: 6; border: 1px solid rgba(255,255,255,.16); padding: 13px; background: rgba(8,28,24,.94); color: white; border-radius: 14px; box-shadow: 0 18px 48px rgba(0,0,0,.38); backdrop-filter: saturate(160%) blur(18px); pointer-events: auto; animation: popover-in .22s cubic-bezier(.22,1,.36,1); }
 
 
 
 
 
 
 
72
  @keyframes popover-in { from { opacity: 0; transform: translateY(7px) scale(.97); } }
73
  .popover-heading { display: grid; grid-template-columns: 9px 1px minmax(0,1fr) auto; align-items: center; gap: 8px; }
74
- .callout-dot { width: 9px; height: 9px; background: var(--lime); border-radius: 50%; box-shadow: 0 0 0 3px rgba(201,242,87,.16); }
75
- .callout-line { align-self: stretch; width: 1px; min-height: 20px; background: rgba(255,255,255,.22); }
76
- .popover-heading strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
77
- .popover-heading small { color: #c4cfcb; font-size: 9px; font-variant-numeric: tabular-nums; }
78
- .term-popover p { margin: 11px 0; color: #d8e1de; font-size: 11px; line-height: 1.5; }
79
- .term-popover button { width: 100%; min-height: 36px; border: 0; background: var(--lime); color: var(--dark); border-radius: 8px; font-size: 10px; font-weight: 750; }
80
- .selection-hint { position: absolute; z-index: 3; left: 50%; bottom: 66px; transform: translateX(-50%); max-width: calc(100% - 32px); padding: 7px 10px; background: rgba(255,255,255,.88); color: var(--dark); border-radius: 99px; backdrop-filter: blur(12px); font-size: 9px; font-weight: 650; text-align: center; white-space: nowrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  .selection-hint[hidden] { display: none; }
82
- .document-info { position: absolute; z-index: 5; left: 14px; bottom: 14px; display: flex; align-items: center; gap: 7px; min-height: 40px; border: 1px solid #ffffff24; padding: 7px 11px 7px 7px; background: rgba(8,28,24,.82); color: white; border-radius: 99px; backdrop-filter: blur(16px); pointer-events: auto; }
 
 
 
 
 
 
 
 
 
 
83
  .document-info[hidden] { display: none; }
84
  .document-info span { display: grid; place-items: center; width: 25px; height: 25px; background: var(--lime); color: var(--dark); border-radius: 50%; font-family: Georgia, serif; font-weight: 800; }
85
  .document-info b { display: none; font-size: 10px; }
 
86
  .selection-layer { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
87
  .selectable-region { position: absolute; display: block; overflow: hidden; color: transparent; line-height: 1; white-space: pre-wrap; user-select: text; -webkit-user-select: text; pointer-events: auto; cursor: text; touch-action: pan-x pan-y; }
 
 
88
  .term-marker.user-triggered { animation: marker-pulse .28s cubic-bezier(.22,1,.36,1); }
89
  @keyframes marker-pulse { from { opacity: 0; transform: scale(.2); } 70% { transform: scale(1.25); } }
90
- .selectable-region::selection { background: rgba(201,242,87,.72); color: rgba(8,28,24,.9); }
91
- @keyframes pop { from { scale: .2; opacity: 0; } }
92
- .corner { position: absolute; width: 34px; height: 34px; border-color: var(--lime); border-style: solid; opacity: .8; }
93
  .tl { top: 28px; left: 20px; border-width: 2px 0 0 2px; }
94
  .tr { top: 28px; right: 20px; border-width: 2px 2px 0 0; }
95
  .bl { bottom: 28px; left: 20px; border-width: 0 0 2px 2px; }
96
  .br { right: 20px; bottom: 28px; border-width: 0 2px 2px 0; }
97
- .empty-state { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; color: white; text-align: center; background: linear-gradient(130deg, #102c27dd, #071a18ee); }
 
 
 
 
 
 
 
 
 
 
 
98
  .empty-state.hidden { display: none; }
99
- .empty-state span { max-width: 250px; color: #a8b7b2; font-size: 14px; }
100
  .lens { margin-bottom: 8px; color: var(--lime); font-size: 50px; }
101
- .primary, .panel-actions button { min-height: 48px; border: 0; border-radius: 12px; padding: 12px 18px; background: var(--lime); color: var(--dark); font-weight: 720; box-shadow: 0 8px 20px #8aaa3030; transition: transform .18s ease, filter .18s ease; }
102
- .primary:hover, .panel-actions button:hover:not(:disabled) { filter: brightness(1.025); transform: translateY(-1px); }
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  .empty-state .primary { margin-top: 14px; }
104
- .scan-line { display: none; position: absolute; left: 7%; right: 7%; top: 50%; height: 1px; background: var(--lime); box-shadow: 0 0 20px var(--lime); animation: scan 2.4s ease-in-out infinite; }
 
 
 
 
 
 
105
  .viewport.active .scan-line { display: block; }
106
- @keyframes scan { 0%, 100% { top: 12%; opacity: .1; } 50% { top: 88%; opacity: .9; } }
107
- .status-pill, .quality-pill { position: absolute; top: 14px; max-width: 46%; overflow: hidden; border: 1px solid #ffffff22; background: #071a18bb; color: #fff; backdrop-filter: blur(10px); border-radius: 99px; padding: 7px 10px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
 
 
 
 
 
 
108
  .status-pill { left: 12px; }
109
- .quality-pill { right: 12px; }
110
- .status-pill i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; background: #80908b; border-radius: 50%; }
111
- .status-pill.live i { background: var(--lime); box-shadow: 0 0 8px var(--lime); }
112
 
113
- .details-toggle { position: absolute; z-index: 3; left: 50%; bottom: 14px; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; min-height: 42px; border: 1px solid #ffffff24; padding: 8px 11px 8px 14px; background: rgba(8,28,24,.76); color: white; border-radius: 99px; backdrop-filter: saturate(160%) blur(16px); box-shadow: 0 8px 30px #0003; font-size: 11px; font-weight: 650; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  .details-toggle i { font-style: normal; transition: transform .3s ease; }
115
  .details-open .details-toggle i { transform: rotate(180deg); }
116
 
117
- .control-panel { position: relative; display: flex; flex-direction: column; max-height: 0; overflow: hidden; padding: 0 18px; opacity: 0; transform: translateY(18px); transition: max-height .45s cubic-bezier(.22,1,.36,1), opacity .25s ease, transform .4s cubic-bezier(.22,1,.36,1), padding .4s ease; }
 
 
 
 
118
  .details-open .control-panel { max-height: 740px; padding: 26px 18px calc(20px + var(--safe-bottom)); opacity: 1; transform: translateY(0); }
119
- .details-close { position: absolute; top: 15px; right: 14px; z-index: 2; display: grid; place-items: center; width: 32px; height: 32px; border: 0; background: #f0f2ef; color: var(--muted); border-radius: 50%; font-size: 21px; line-height: 1; }
 
 
 
 
 
 
 
 
 
120
  .panel-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 38px 14px 0; }
121
- .panel-heading h2 { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-size: 24px; font-weight: 680; letter-spacing: -.035em; }
122
- .panel-heading > span { flex: 0 0 auto; padding: 6px 9px; background: #eef0e8; border-radius: 99px; font-size: 11px; }
123
  .status-message { padding: 14px 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
 
124
  .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
125
- .metrics > div { min-width: 0; background: #f3f4ef; padding: 10px 8px; border-radius: 9px; }
126
  .metrics small, .metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; }
127
  .metrics small { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
128
- .metrics strong { margin-top: 5px; font-size: 11px; white-space: nowrap; }
 
129
  .terms-list { max-height: 230px; overflow: auto; padding: 14px 0; }
130
  .hint-card { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 13px; }
131
  .hint-card span { color: var(--green); font-family: Georgia, serif; font-size: 22px; }
132
- .instruction-card { padding: 13px 14px; background: #f3f5f1; border-radius: 12px; }
133
- .instruction-card strong { font-size: 12px; }
 
134
  .instruction-card p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
135
- .analysis-progress { display: flex; align-items: center; gap: 10px; padding: 14px; background: #f3f5f1; border-radius: 12px; color: var(--muted); font-size: 11px; }
136
- .analysis-progress i { width: 16px; height: 16px; border: 2px solid #d9dfdb; border-top-color: var(--green); border-radius: 50%; animation: progress-spin .8s linear infinite; }
 
137
  @keyframes progress-spin { to { transform: rotate(360deg); } }
138
- .empty-result { padding: 14px; border: 1px dashed #d5dbd7; color: var(--muted); border-radius: 12px; font-size: 11px; line-height: 1.5; }
139
- .upload-card { display: grid; gap: 10px; margin: 4px 0 14px; padding: 13px; border: 1px solid var(--line); background: #f6f7f4; border-radius: 12px; }
 
140
  .upload-card strong, .upload-card span { display: block; }
141
- .upload-card strong { font-size: 12px; }
142
  .upload-card span { margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.4; }
143
- .upload-card button { min-height: 40px; border: 1px solid #ced5d1; background: white; color: var(--ink); border-radius: 9px; font-size: 11px; font-weight: 700; }
144
- .upload-card button:hover { border-color: var(--green); color: var(--green); }
 
 
 
 
 
145
  .panel-actions { display: grid; gap: 8px; }
146
- .panel-actions button:disabled { opacity: .4; cursor: not-allowed; }
147
- .panel-actions .secondary { background: var(--dark); color: white; }
 
 
 
 
 
 
 
148
 
149
- .trust-row { display: grid; grid-template-columns: 1fr 1fr; margin-top: 18px; overflow: hidden; border: 1px solid rgba(20,35,31,.08); background: var(--glass); border-radius: 16px; box-shadow: 0 12px 40px rgba(20,38,33,.05); }
150
- .trust-row div { display: flex; flex-direction: column; padding: 15px; border-right: 1px solid #d8d8cf; border-bottom: 1px solid #d8d8cf; }
151
  .trust-row div:nth-child(2n) { border-right: 0; }
152
  .trust-row div:nth-last-child(-n+2) { border-bottom: 0; }
153
- .trust-row b { color: var(--green); font-size: 9px; letter-spacing: .1em; }
154
  .trust-row span { margin-top: 4px; color: var(--muted); font-size: 11px; }
155
 
156
- .modal { position: fixed; inset: 0; z-index: 20; display: none; place-items: end center; padding: 12px 12px calc(12px + var(--safe-bottom)); background: #071a18aa; backdrop-filter: blur(5px); }
 
 
 
 
 
157
  .modal.open { display: grid; }
158
- .definition-card, .admin-card, .insight-card { position: relative; width: 100%; max-height: 88svh; overflow: auto; background: white; border-radius: 20px 20px 14px 14px; padding: 28px 20px; box-shadow: 0 30px 100px #0006; }
159
- .close { position: absolute; top: 10px; right: 14px; border: 0; background: transparent; color: var(--muted); font-size: 27px; }
160
- .definition-card h2, .admin-card h2, .insight-card h2 { margin: 0 0 2px; font-family: Georgia, serif; font-size: 32px; }
161
- .full-form { margin: 0; color: var(--green); font-weight: 700; }
162
- .definition-text { font-family: Georgia, serif; font-size: 20px; line-height: 1.45; }
163
- .definition-meta { display: grid; gap: 5px; padding: 10px; background: #f0f3ec; border-radius: 9px; font-size: 11px; }
 
 
 
 
 
 
 
 
 
 
 
 
164
  .related { padding: 12px 0; color: var(--muted); font-size: 11px; }
 
165
  .feedback-row { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--line); padding-top: 16px; }
166
  .feedback-row span { margin-right: auto; font-size: 12px; }
167
- .feedback-row button { width: 42px; height: 40px; border: 1px solid var(--line); border-radius: 8px; background: white; }
168
- textarea { width: 100%; min-height: 90px; margin: 7px 0 9px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; }
169
- form label { font-size: 12px; font-weight: 700; }
 
 
 
 
 
 
 
 
 
 
 
170
  #correctionForm { margin-top: 15px; }
171
- .feedback-message { color: var(--green); font-size: 12px; }
172
- .insight-card blockquote { margin: 16px 0; padding: 12px 14px; background: #f2f4f0; color: var(--muted); border-left: 3px solid var(--lime); border-radius: 4px 10px 10px 4px; font-size: 12px; line-height: 1.45; }
 
173
  .insight-card section { padding: 13px 0; border-top: 1px solid var(--line); }
174
- .insight-card section small { color: var(--green); font-size: 9px; font-weight: 800; letter-spacing: .14em; }
175
  .insight-card section p { margin: 7px 0 0; color: var(--ink); font-size: 14px; line-height: 1.6; }
 
176
  .insight-terms { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 16px; }
177
- .insight-terms button { border: 1px solid #ced8d3; padding: 6px 9px; background: #f7faf7; color: var(--green); border-radius: 99px; font-size: 10px; font-weight: 700; }
178
- .admin-item { margin-top: 12px; padding: 16px; border: 1px solid var(--line); border-radius: 12px; }
179
- .admin-item h3 { margin: 0; }
 
 
 
 
 
 
180
  .admin-item p { font-size: 13px; }
181
  .admin-actions { display: flex; gap: 8px; margin-top: 12px; }
182
- .admin-actions button { min-height: 40px; border: 0; border-radius: 7px; padding: 8px 12px; }
183
- .approve { background: var(--lime); }
184
- .reject { background: #f1dddd; }
185
  .muted { color: var(--muted); }
186
- .toast { position: fixed; left: 14px; right: 14px; bottom: calc(16px + var(--safe-bottom)); z-index: 30; transform: translateY(20px); opacity: 0; background: var(--dark); color: white; padding: 11px 16px; border-radius: 9px; transition: .2s; text-align: center; }
 
 
 
 
 
 
187
  .toast.show { opacity: 1; transform: translateY(0); }
188
  [dir=rtl] .definition-card { text-align: right; }
189
 
@@ -217,18 +468,28 @@ form label { font-size: 12px; font-weight: 700; }
217
  .scanner-shell { display: grid; grid-template-columns: minmax(0, 1fr) 0; min-height: 590px; border-radius: 24px; }
218
  .scanner-shell.details-open { grid-template-columns: minmax(0, 1.65fr) minmax(330px, .7fr); }
219
  .viewport { min-height: 590px; }
220
- .control-panel { max-height: none; min-width: 0; height: 590px; padding: 28px; opacity: 1; transform: translateX(24px); transition: opacity .22s ease, transform .42s cubic-bezier(.22,1,.36,1); }
 
 
 
 
221
  .details-closed .control-panel { visibility: hidden; padding-inline: 0; opacity: 0; pointer-events: none; }
222
  .details-open .control-panel { max-height: none; padding: 28px; opacity: 1; transform: translateX(0); }
223
  .details-toggle { left: auto; right: 18px; bottom: 18px; transform: none; }
224
  .details-open .details-toggle { right: 18px; }
225
  .panel-actions { grid-template-columns: 1fr; }
226
  .terms-list { flex: 1; max-height: 300px; }
 
227
  .trust-row { grid-template-columns: repeat(4, 1fr); margin-top: 20px; }
228
- .trust-row div, .trust-row div:nth-child(2n) { border-right: 1px solid #d8d8cf; border-bottom: 0; padding: 18px 24px; }
229
  .trust-row div:last-child { border-right: 0; }
230
  }
231
 
232
  @media (prefers-reduced-motion: reduce) {
233
  *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
234
  }
 
 
 
 
 
 
1
  :root {
2
+ --ink: #f1f6f4;
3
+ --muted: #8ea7a1;
4
+ --paper: #09100e;
5
+ --white: #0e1715;
6
+ --green: #10b981;
7
+ --lime: #b5f21d; /* Electric neon lime accent */
8
+ --line: #1b2d29;
9
+ --dark: #050807;
10
+ --glass: rgba(14, 23, 21, 0.72);
11
+ --shadow: 0 25px 80px rgba(0, 0, 0, 0.65);
12
  --safe-bottom: env(safe-area-inset-bottom, 0px);
13
+ --selection-bg: rgba(181, 242, 29, 0.35);
14
  }
15
 
16
  * { box-sizing: border-box; }
17
  html { scroll-behavior: smooth; }
18
+ body {
19
+ margin: 0;
20
+ min-width: 320px;
21
+ background: radial-gradient(circle at 50% -20%, #172c26 0%, var(--paper) 68%);
22
+ color: var(--ink);
23
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, ui-sans-serif, system-ui, sans-serif;
24
+ -webkit-font-smoothing: antialiased;
25
+ }
26
  button, select, textarea { font: inherit; }
27
  button { cursor: pointer; }
28
 
29
+ /* Global Focus Outline for Accessibility */
30
+ button:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
31
+ outline: 2px solid var(--lime);
32
+ outline-offset: 3px;
33
+ }
34
+
35
+ /* Header styling: Translucent & blur effects */
36
  header {
37
  position: sticky; top: 0; z-index: 10;
38
  min-height: 64px; padding: 10px 14px;
39
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
40
+ border-bottom: 1px solid var(--line);
41
+ background: rgba(9, 16, 14, 0.78);
42
  backdrop-filter: saturate(180%) blur(20px);
43
  }
44
  .brand { display: flex; align-items: center; gap: 9px; min-width: 0; text-decoration: none; color: var(--ink); font-weight: 800; font-size: 16px; }
45
  .brand small { display: none; font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); margin-top: 2px; }
46
+
47
+ /* Interactive Monogram Mark */
48
+ .mark {
49
+ position: relative; display: block; flex: 0 0 auto; width: 38px; height: 38px;
50
+ overflow: hidden; background: var(--dark); border-radius: 11px;
51
+ box-shadow: 0 7px 18px rgba(0, 0, 0, 0.3); isolation: isolate;
52
+ transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.3s ease;
53
+ }
54
+ .brand:hover .mark {
55
+ transform: rotate(6deg) scale(1.05);
56
+ box-shadow: 0 0 16px rgba(181, 242, 29, 0.25);
57
+ }
58
+ .mark::before { content: ""; position: absolute; z-index: 0; width: 30px; height: 8px; left: 5px; top: 15px; background: linear-gradient(90deg, var(--lime), var(--green)); border-radius: 99px; transform: rotate(-43deg); opacity: .45; }
59
+ .mark-f, .mark-s { position: absolute; z-index: 1; top: 5px; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-size: 24px; font-weight: 850; line-height: 28px; letter-spacing: -.12em; }
60
  .mark-f { left: 7px; color: var(--lime); }
61
  .mark-s { left: 17px; color: #fff; mix-blend-mode: screen; }
62
+
63
  .header-actions { display: flex; align-items: center; gap: 6px; }
64
+ .header-actions select, .ghost {
65
+ min-height: 38px; border: 1px solid var(--line);
66
+ background: rgba(255, 255, 255, 0.05); border-radius: 11px;
67
+ padding: 7px 9px; color: var(--ink);
68
+ transition: border-color 0.2s, background 0.2s;
69
+ }
70
+ .header-actions select:hover, .ghost:hover {
71
+ border-color: rgba(181, 242, 29, 0.5);
72
+ background: rgba(255, 255, 255, 0.08);
73
+ }
74
+ .header-actions select option { background: var(--paper); color: var(--ink); }
75
  .ghost { width: 42px; overflow: hidden; white-space: nowrap; font-size: 0; }
76
  .ghost::before { content: "SME"; font-size: 10px; font-weight: 800; }
77
+ .count { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin-left: 2px; padding: 0 5px; background: var(--lime); border-radius: 99px; font-size: 10px; color: var(--dark); font-weight: 700; }
78
  .count[hidden] { display: none; }
79
 
80
  main { width: 100%; max-width: 1320px; margin: auto; padding: 30px 14px calc(64px + var(--safe-bottom)); }
81
  .intro { margin-bottom: 18px; }
82
+
83
+ /* Subtle title text gradient */
84
+ .intro h1 {
85
+ max-width: 10ch; margin: 2px 0 12px;
86
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif;
87
+ font-weight: 780; font-size: clamp(39px, 12vw, 64px); line-height: .98; letter-spacing: -.055em;
88
+ background: linear-gradient(135deg, #ffffff 40%, var(--muted) 100%);
89
+ -webkit-background-clip: text;
90
+ -webkit-text-fill-color: transparent;
91
+ }
92
  .intro > div > p:last-child { max-width: 680px; margin: 0; color: var(--muted); line-height: 1.55; font-size: 14px; }
93
+ .eyebrow { margin: 0 0 8px; color: var(--green); font-size: 9px; letter-spacing: .18em; font-weight: 800; }
94
+
95
+ /* Private by design trust-note */
96
+ .trust-note {
97
+ display: grid; grid-template-columns: 34px 1fr; gap: 11px; align-items: center; margin: 18px 0;
98
+ padding: 13px 14px; border: 1px solid rgba(16, 185, 129, 0.25);
99
+ background: rgba(16, 185, 129, 0.06); border-radius: 15px;
100
+ }
101
+ .trust-icon { display: grid; place-items: center; width: 34px; height: 34px; background: rgba(16, 185, 129, 0.15); color: var(--green); border-radius: 50%; font-weight: 800; }
102
+ .trust-note strong { display: block; font-size: 13px; color: #fff; }
103
+ .trust-note p { margin: 2px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
104
+ .trust-label { display: none; color: var(--lime); font-size: 9px; font-weight: 800; letter-spacing: .13em; }
105
+
106
+ /* Scanner Shell: Sleek border transitions and glows */
107
+ .scanner-shell {
108
+ display: flex; flex-direction: column; min-height: 0; overflow: hidden;
109
+ background: var(--white); border: 1px solid var(--line);
110
+ border-radius: 20px; box-shadow: var(--shadow);
111
+ transition: grid-template-columns .42s cubic-bezier(.22,1,.36,1), border-color 0.3s, box-shadow 0.3s;
112
+ }
113
+ .scanner-shell:hover {
114
+ border-color: rgba(181, 242, 29, 0.15);
115
+ box-shadow: 0 0 35px rgba(181, 242, 29, 0.05), var(--shadow);
116
+ }
117
+ .viewport { position: relative; min-height: min(62svh, 560px); background: radial-gradient(circle at center, #112722, #040908 72%); overflow: hidden; }
118
  .viewport video { position: absolute; width: 100%; height: 100%; object-fit: contain; }
119
+
120
+ /* Document scroller styling */
121
+ .document-scroller {
122
+ position: absolute; inset: 0; z-index: 1; overflow: auto; overscroll-behavior: contain;
123
+ scrollbar-gutter: stable; scrollbar-color: #556c65 var(--paper); scrollbar-width: auto;
124
+ background: var(--paper); -webkit-overflow-scrolling: touch; touch-action: pan-x pan-y;
125
+ }
126
  .document-scroller[hidden] { display: none; }
127
  .document-scroller::-webkit-scrollbar { width: 11px; height: 11px; }
128
+ .document-scroller::-webkit-scrollbar-track { background: var(--paper); }
129
+ .document-scroller::-webkit-scrollbar-thumb { border: 3px solid var(--paper); background: #556c65; border-radius: 99px; }
130
  .document-stage { position: relative; width: 100%; min-height: 100%; background: #fff; }
131
  .uploaded-preview { display: block; width: 100%; height: auto; min-height: 100%; object-fit: contain; object-position: top center; background: #fff; }
132
  .document-stage > .overlay { position: absolute; inset: 0; width: 100%; height: 100%; }
133
  .overlay { position: absolute; inset: 0; pointer-events: none; }
134
+
135
+ /* Term markers and active glow overlays */
136
+ .term-marker {
137
+ position: absolute; z-index: 4; display: grid; place-items: center; width: 18px; height: 18px;
138
+ border: 1.5px solid rgba(255,255,255,.9); padding: 0; background: rgba(5,8,7,.9); border-radius: 50%;
139
+ box-shadow: 0 4px 12px rgba(0,0,0,.5); pointer-events: auto; cursor: grab; touch-action: none;
140
+ animation: marker-in .35s var(--marker-delay, 0ms) both cubic-bezier(.22,1,.36,1);
141
+ transition: border-color 0.2s, transform 0.2s;
142
+ }
143
  .term-marker:active { cursor: grabbing; }
144
+ .term-marker span {
145
+ width: 6px; height: 6px; background: var(--lime); border-radius: 50%;
146
+ box-shadow: 0 0 0 3px rgba(181, 242, 29, 0.25);
147
+ transition: transform 0.2s;
148
+ }
149
+ .term-marker:hover, .term-marker:focus-visible, .term-marker.active {
150
+ border-color: var(--lime); transform: scale(1.15);
151
+ box-shadow: 0 0 10px rgba(181, 242, 29, 0.4);
152
+ }
153
+ .term-marker:hover span { transform: scale(1.2); }
154
  @keyframes marker-in { from { opacity: 0; transform: scale(.4); } }
155
+
156
+ /* Dynamic popover over document elements */
157
+ .term-popover {
158
+ position: absolute; z-index: 6; border: 1px solid rgba(255,255,255,.12); padding: 13px;
159
+ background: rgba(14, 23, 21, 0.94); color: white; border-radius: 14px;
160
+ box-shadow: 0 18px 48px rgba(0,0,0,.6); backdrop-filter: saturate(160%) blur(18px);
161
+ pointer-events: auto; animation: popover-in .22s cubic-bezier(.22,1,.36,1);
162
+ }
163
  @keyframes popover-in { from { opacity: 0; transform: translateY(7px) scale(.97); } }
164
  .popover-heading { display: grid; grid-template-columns: 9px 1px minmax(0,1fr) auto; align-items: center; gap: 8px; }
165
+ .callout-dot { width: 9px; height: 9px; background: var(--lime); border-radius: 50%; box-shadow: 0 0 0 3px rgba(181, 242, 29, 0.2); }
166
+ .callout-line { align-self: stretch; width: 1px; min-height: 20px; background: rgba(255,255,255,.15); }
167
+ .popover-heading strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; color: #fff; }
168
+ .popover-heading small { color: var(--muted); font-size: 9px; font-variant-numeric: tabular-nums; }
169
+ .term-popover p { margin: 11px 0; color: #d1deda; font-size: 11px; line-height: 1.5; }
170
+
171
+ /* Interactive button effects */
172
+ .term-popover button {
173
+ width: 100%; min-height: 36px; border: 0; background: var(--lime); color: var(--dark);
174
+ border-radius: 8px; font-size: 10px; font-weight: 750;
175
+ transition: filter 0.15s, transform 0.15s;
176
+ }
177
+ .term-popover button:hover {
178
+ filter: brightness(1.05); transform: translateY(-0.5px);
179
+ }
180
+
181
+ .selection-hint {
182
+ position: absolute; z-index: 3; left: 50%; bottom: 58px; transform: translateX(-50%);
183
+ max-width: calc(100% - 32px); padding: 7px 12px; background: rgba(14, 23, 21, 0.86);
184
+ color: var(--ink); border: 1px solid var(--line); border-radius: 99px;
185
+ backdrop-filter: blur(12px); font-size: 9px; font-weight: 650; text-align: center;
186
+ white-space: nowrap; box-shadow: 0 4px 15px rgba(0,0,0,0.3);
187
+ }
188
  .selection-hint[hidden] { display: none; }
189
+
190
+ .document-info {
191
+ position: absolute; z-index: 5; right: 12px; top: 14px; display: flex; align-items: center; gap: 7px;
192
+ min-height: 40px; border: 1px solid rgba(255, 255, 255, 0.15); padding: 7px 11px 7px 7px;
193
+ background: rgba(14, 23, 21, 0.85); color: white; border-radius: 99px;
194
+ backdrop-filter: blur(16px); pointer-events: auto; transition: background 0.2s, border-color 0.2s;
195
+ }
196
+ .document-info:hover {
197
+ background: rgba(22, 38, 34, 0.9);
198
+ border-color: rgba(181, 242, 29, 0.4);
199
+ }
200
  .document-info[hidden] { display: none; }
201
  .document-info span { display: grid; place-items: center; width: 25px; height: 25px; background: var(--lime); color: var(--dark); border-radius: 50%; font-family: Georgia, serif; font-weight: 800; }
202
  .document-info b { display: none; font-size: 10px; }
203
+
204
  .selection-layer { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
205
  .selectable-region { position: absolute; display: block; overflow: hidden; color: transparent; line-height: 1; white-space: pre-wrap; user-select: text; -webkit-user-select: text; pointer-events: auto; cursor: text; touch-action: pan-x pan-y; }
206
+ .selectable-region::selection { background: var(--selection-bg); color: rgba(255,255,255,0.9); }
207
+
208
  .term-marker.user-triggered { animation: marker-pulse .28s cubic-bezier(.22,1,.36,1); }
209
  @keyframes marker-pulse { from { opacity: 0; transform: scale(.2); } 70% { transform: scale(1.25); } }
210
+
211
+ /* Corner brackets: pulsing scan visual states */
212
+ .corner { position: absolute; width: 34px; height: 34px; border-color: var(--lime); border-style: solid; opacity: .45; }
213
  .tl { top: 28px; left: 20px; border-width: 2px 0 0 2px; }
214
  .tr { top: 28px; right: 20px; border-width: 2px 2px 0 0; }
215
  .bl { bottom: 28px; left: 20px; border-width: 0 0 2px 2px; }
216
  .br { right: 20px; bottom: 28px; border-width: 0 2px 2px 0; }
217
+ @keyframes corner-glow {
218
+ 0%, 100% { opacity: 0.45; }
219
+ 50% { opacity: 0.95; filter: drop-shadow(0 0 4px var(--lime)); }
220
+ }
221
+ .viewport.active .corner {
222
+ animation: corner-glow 2.2s infinite ease-in-out;
223
+ }
224
+
225
+ .empty-state {
226
+ position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 24px; color: white; text-align: center;
227
+ background: linear-gradient(130deg, rgba(14, 28, 25, 0.94), rgba(7, 14, 12, 0.98));
228
+ }
229
  .empty-state.hidden { display: none; }
230
+ .empty-state span { max-width: 250px; color: var(--muted); font-size: 14px; }
231
  .lens { margin-bottom: 8px; color: var(--lime); font-size: 50px; }
232
+
233
+ /* Buttons & drawer layout overrides */
234
+ .primary, .panel-actions button {
235
+ min-height: 48px; border: 0; border-radius: 12px; padding: 12px 18px;
236
+ background: var(--lime); color: var(--dark); font-weight: 750;
237
+ box-shadow: 0 8px 20px rgba(181, 242, 29, 0.15);
238
+ transition: transform .18s ease, filter .18s ease, box-shadow .18s ease;
239
+ }
240
+ .primary:hover, .panel-actions button:hover:not(:disabled) {
241
+ filter: brightness(1.05); transform: translateY(-1.5px);
242
+ box-shadow: 0 10px 24px rgba(181, 242, 29, 0.25);
243
+ }
244
+ .primary:active, .panel-actions button:active:not(:disabled) {
245
+ transform: translateY(0);
246
+ }
247
  .empty-state .primary { margin-top: 14px; }
248
+
249
+ /* Active scanning laser bar */
250
+ .scan-line {
251
+ display: none; position: absolute; left: 7%; right: 7%; top: 50%; height: 2px;
252
+ background: linear-gradient(90deg, transparent, var(--lime), transparent);
253
+ box-shadow: 0 0 15px var(--lime); animation: scan 2.5s ease-in-out infinite;
254
+ }
255
  .viewport.active .scan-line { display: block; }
256
+ @keyframes scan { 0%, 100% { top: 12%; opacity: .15; } 50% { top: 88%; opacity: .95; } }
257
+
258
+ .status-pill, .quality-pill {
259
+ position: absolute; top: 14px; max-width: 46%; overflow: hidden;
260
+ border: 1px solid rgba(255, 255, 255, 0.15); background: rgba(9, 16, 14, 0.82);
261
+ color: #fff; backdrop-filter: blur(12px); border-radius: 99px;
262
+ padding: 7px 12px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap;
263
+ }
264
  .status-pill { left: 12px; }
265
+ .quality-pill { right: 12px; top: 58px; }
266
+ .status-pill i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; background: #647570; border-radius: 50%; }
 
267
 
268
+ /* Status pill indicator pulsing animation */
269
+ @keyframes pulse-live {
270
+ 0% { box-shadow: 0 0 0 0 rgba(181, 242, 29, 0.6); }
271
+ 70% { box-shadow: 0 0 0 6px rgba(181, 242, 29, 0); }
272
+ 100% { box-shadow: 0 0 0 0 rgba(181, 242, 29, 0); }
273
+ }
274
+ .status-pill.live i {
275
+ background: var(--lime);
276
+ animation: pulse-live 1.8s infinite ease-in-out;
277
+ }
278
+
279
+ /* Drawer Toggle Control */
280
+ .details-toggle {
281
+ position: absolute; z-index: 3; left: 50%; bottom: 14px; transform: translateX(-50%);
282
+ display: flex; align-items: center; gap: 8px; min-height: 42px;
283
+ border: 1px solid rgba(255, 255, 255, 0.16); padding: 8px 11px 8px 14px;
284
+ background: rgba(14, 23, 21, 0.85); color: white; border-radius: 99px;
285
+ backdrop-filter: saturate(160%) blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.4);
286
+ font-size: 11px; font-weight: 650; transition: border-color 0.2s, background 0.2s;
287
+ }
288
+ .details-toggle:hover {
289
+ background: rgba(22, 38, 34, 0.95);
290
+ border-color: rgba(181, 242, 29, 0.3);
291
+ }
292
  .details-toggle i { font-style: normal; transition: transform .3s ease; }
293
  .details-open .details-toggle i { transform: rotate(180deg); }
294
 
295
+ .control-panel {
296
+ position: relative; display: flex; flex-direction: column; max-height: 0; overflow: hidden;
297
+ padding: 0 18px; opacity: 0; transform: translateY(18px);
298
+ transition: max-height .45s cubic-bezier(.22,1,.36,1), opacity .25s ease, transform .4s cubic-bezier(.22,1,.36,1), padding .4s ease;
299
+ }
300
  .details-open .control-panel { max-height: 740px; padding: 26px 18px calc(20px + var(--safe-bottom)); opacity: 1; transform: translateY(0); }
301
+ .details-close {
302
+ position: absolute; top: 15px; right: 14px; z-index: 2; display: grid; place-items: center;
303
+ width: 32px; height: 32px; border: 0; background: rgba(255,255,255,0.06);
304
+ color: var(--muted); border-radius: 50%; font-size: 21px; line-height: 1;
305
+ transition: background 0.2s, color 0.2s;
306
+ }
307
+ .details-close:hover {
308
+ background: rgba(255,255,255,0.12);
309
+ color: var(--lime);
310
+ }
311
  .panel-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 38px 14px 0; }
312
+ .panel-heading h2 { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, sans-serif; font-size: 24px; font-weight: 780; letter-spacing: -.035em; color: #fff; }
313
+ .panel-heading > span { flex: 0 0 auto; padding: 6px 9px; background: rgba(255,255,255,0.05); border-radius: 99px; font-size: 11px; color: var(--lime); font-weight: 600; }
314
  .status-message { padding: 14px 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
315
+
316
  .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
317
+ .metrics > div { min-width: 0; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.04); padding: 10px 8px; border-radius: 9px; }
318
  .metrics small, .metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; }
319
  .metrics small { color: var(--muted); font-size: 8px; text-transform: uppercase; letter-spacing: .08em; }
320
+ .metrics strong { margin-top: 5px; font-size: 11px; white-space: nowrap; color: #fff; }
321
+
322
  .terms-list { max-height: 230px; overflow: auto; padding: 14px 0; }
323
  .hint-card { display: flex; gap: 12px; align-items: center; color: var(--muted); font-size: 13px; }
324
  .hint-card span { color: var(--green); font-family: Georgia, serif; font-size: 22px; }
325
+
326
+ .instruction-card { padding: 13px 14px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); border-radius: 12px; }
327
+ .instruction-card strong { font-size: 12px; color: #fff; }
328
  .instruction-card p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
329
+
330
+ .analysis-progress { display: flex; align-items: center; gap: 10px; padding: 14px; background: rgba(255,255,255,0.03); border-radius: 12px; color: var(--muted); font-size: 11px; }
331
+ .analysis-progress i { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.1); border-top-color: var(--lime); border-radius: 50%; animation: progress-spin .8s linear infinite; }
332
  @keyframes progress-spin { to { transform: rotate(360deg); } }
333
+ .empty-result { padding: 14px; border: 1px dashed rgba(255,255,255,0.12); color: var(--muted); border-radius: 12px; font-size: 11px; line-height: 1.5; }
334
+
335
+ .upload-card { display: grid; gap: 10px; margin: 4px 0 14px; padding: 13px; border: 1px solid var(--line); background: rgba(255,255,255,0.02); border-radius: 12px; }
336
  .upload-card strong, .upload-card span { display: block; }
337
+ .upload-card strong { font-size: 12px; color: #fff; }
338
  .upload-card span { margin-top: 3px; color: var(--muted); font-size: 10px; line-height: 1.4; }
339
+ .upload-card button {
340
+ min-height: 40px; border: 1px solid var(--line); background: rgba(255,255,255,0.04);
341
+ color: var(--ink); border-radius: 9px; font-size: 11px; font-weight: 700;
342
+ transition: border-color 0.2s, color 0.2s, background 0.2s;
343
+ }
344
+ .upload-card button:hover { border-color: var(--lime); color: var(--lime); background: rgba(255,255,255,0.08); }
345
+
346
  .panel-actions { display: grid; gap: 8px; }
347
+ .panel-actions button:disabled { opacity: .3; cursor: not-allowed; box-shadow: none; transform: none; }
348
+ .panel-actions .secondary {
349
+ background: rgba(255, 255, 255, 0.08); color: white; border: 1px solid var(--line);
350
+ box-shadow: none;
351
+ }
352
+ .panel-actions .secondary:hover:not(:disabled) {
353
+ background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.25);
354
+ box-shadow: none;
355
+ }
356
 
357
+ .trust-row { display: grid; grid-template-columns: 1fr 1fr; margin-top: 18px; overflow: hidden; border: 1px solid var(--line); background: var(--glass); border-radius: 16px; box-shadow: var(--shadow); }
358
+ .trust-row div { display: flex; flex-direction: column; padding: 15px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
359
  .trust-row div:nth-child(2n) { border-right: 0; }
360
  .trust-row div:nth-last-child(-n+2) { border-bottom: 0; }
361
+ .trust-row b { color: var(--lime); font-size: 9px; letter-spacing: .1em; font-weight: 700; }
362
  .trust-row span { margin-top: 4px; color: var(--muted); font-size: 11px; }
363
 
364
+ /* Modals & sheets details */
365
+ .modal {
366
+ position: fixed; inset: 0; z-index: 20; display: none; place-items: end center;
367
+ padding: 12px 12px calc(12px + var(--safe-bottom)); background: rgba(3, 5, 4, 0.78);
368
+ backdrop-filter: blur(10px);
369
+ }
370
  .modal.open { display: grid; }
371
+ .definition-card, .admin-card, .insight-card {
372
+ position: relative; width: 100%; max-height: 88svh; overflow: auto;
373
+ background: rgba(17, 28, 25, 0.97); border: 1px solid rgba(255, 255, 255, 0.12);
374
+ border-radius: 20px 20px 14px 14px; padding: 28px 20px; box-shadow: 0 30px 100px rgba(0,0,0,0.7);
375
+ }
376
+ .close {
377
+ position: absolute; top: 10px; right: 14px; border: 0; background: transparent;
378
+ color: var(--muted); font-size: 27px; line-height: 1; transition: color 0.15s;
379
+ }
380
+ .close:hover { color: var(--lime); }
381
+ .definition-card h2, .admin-card h2, .insight-card h2 { margin: 0 0 2px; font-family: Georgia, serif; font-size: 24px; color: #fff; }
382
+ .full-form { margin: 0; color: var(--lime); font-weight: 700; }
383
+ .definition-text { font-family: Georgia, serif; font-size: 20px; line-height: 1.45; color: #f2fbf7; }
384
+
385
+ .definition-meta {
386
+ display: grid; gap: 5px; padding: 10px; background: rgba(255, 255, 255, 0.04);
387
+ border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 9px; font-size: 11px;
388
+ }
389
  .related { padding: 12px 0; color: var(--muted); font-size: 11px; }
390
+
391
  .feedback-row { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--line); padding-top: 16px; }
392
  .feedback-row span { margin-right: auto; font-size: 12px; }
393
+ .feedback-row button {
394
+ width: 42px; height: 40px; border: 1px solid var(--line); border-radius: 8px;
395
+ background: rgba(255,255,255,0.04); color: var(--ink);
396
+ transition: border-color 0.2s, background 0.2s;
397
+ }
398
+ .feedback-row button:hover { border-color: var(--lime); background: rgba(255,255,255,0.08); }
399
+
400
+ textarea {
401
+ width: 100%; min-height: 90px; margin: 7px 0 9px; padding: 10px;
402
+ border: 1px solid var(--line); border-radius: 8px; background: rgba(0,0,0,0.25);
403
+ color: var(--ink);
404
+ }
405
+ textarea:focus { border-color: var(--lime); outline: none; }
406
+ form label { font-size: 12px; font-weight: 700; color: var(--muted); }
407
  #correctionForm { margin-top: 15px; }
408
+ .feedback-message { color: var(--lime); font-size: 12px; }
409
+
410
+ .insight-card blockquote { margin: 16px 0; padding: 12px 14px; background: rgba(255,255,255,0.03); color: var(--muted); border-left: 3px solid var(--lime); border-radius: 4px 10px 10px 4px; font-size: 12px; line-height: 1.45; }
411
  .insight-card section { padding: 13px 0; border-top: 1px solid var(--line); }
412
+ .insight-card section small { color: var(--lime); font-size: 9px; font-weight: 800; letter-spacing: .14em; }
413
  .insight-card section p { margin: 7px 0 0; color: var(--ink); font-size: 14px; line-height: 1.6; }
414
+
415
  .insight-terms { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 16px; }
416
+ .insight-terms button {
417
+ border: 1px solid var(--line); padding: 6px 9px; background: rgba(255,255,255,0.03);
418
+ color: var(--lime); border-radius: 99px; font-size: 10px; font-weight: 700;
419
+ transition: background 0.2s, border-color 0.2s;
420
+ }
421
+ .insight-terms button:hover { background: rgba(181, 242, 29, 0.08); border-color: rgba(181, 242, 29, 0.35); }
422
+
423
+ .admin-item { margin-top: 12px; padding: 16px; border: 1px solid var(--line); border-radius: 12px; background: rgba(255,255,255,0.02); }
424
+ .admin-item h3 { margin: 0; color: #fff; }
425
  .admin-item p { font-size: 13px; }
426
  .admin-actions { display: flex; gap: 8px; margin-top: 12px; }
427
+ .admin-actions button { min-height: 40px; border: 0; border-radius: 7px; padding: 8px 12px; font-weight: 700; }
428
+ .approve { background: var(--lime); color: var(--dark); }
429
+ .reject { background: #4f2222; color: #f5c4c4; border: 1px solid #733131; }
430
  .muted { color: var(--muted); }
431
+
432
+ .toast {
433
+ position: fixed; left: 14px; right: 14px; bottom: calc(16px + var(--safe-bottom)); z-index: 30;
434
+ transform: translateY(20px); opacity: 0; background: var(--dark); color: white;
435
+ border: 1px solid var(--line); padding: 11px 16px; border-radius: 9px;
436
+ transition: .25s ease-out; text-align: center; box-shadow: var(--shadow);
437
+ }
438
  .toast.show { opacity: 1; transform: translateY(0); }
439
  [dir=rtl] .definition-card { text-align: right; }
440
 
 
468
  .scanner-shell { display: grid; grid-template-columns: minmax(0, 1fr) 0; min-height: 590px; border-radius: 24px; }
469
  .scanner-shell.details-open { grid-template-columns: minmax(0, 1.65fr) minmax(330px, .7fr); }
470
  .viewport { min-height: 590px; }
471
+
472
+ .control-panel {
473
+ max-height: none; min-width: 0; height: 590px; padding: 28px; opacity: 1;
474
+ transform: translateX(24px); transition: opacity .22s ease, transform .42s cubic-bezier(.22,1,.36,1);
475
+ }
476
  .details-closed .control-panel { visibility: hidden; padding-inline: 0; opacity: 0; pointer-events: none; }
477
  .details-open .control-panel { max-height: none; padding: 28px; opacity: 1; transform: translateX(0); }
478
  .details-toggle { left: auto; right: 18px; bottom: 18px; transform: none; }
479
  .details-open .details-toggle { right: 18px; }
480
  .panel-actions { grid-template-columns: 1fr; }
481
  .terms-list { flex: 1; max-height: 300px; }
482
+
483
  .trust-row { grid-template-columns: repeat(4, 1fr); margin-top: 20px; }
484
+ .trust-row div, .trust-row div:nth-child(2n) { border-right: 1px solid var(--line); border-bottom: 0; padding: 18px 24px; }
485
  .trust-row div:last-child { border-right: 0; }
486
  }
487
 
488
  @media (prefers-reduced-motion: reduce) {
489
  *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
490
  }
491
+
492
+ .conf-good { color: var(--lime) !important; font-weight: 700; }
493
+ .conf-neutral { color: #f59e0b !important; font-weight: 600; }
494
+ .conf-bad { color: #ef4444 !important; font-weight: 600; }
495
+