Spaces:
Runtime error
Runtime error
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 +11 -1
- backend/document_service.py +1 -1
- backend/glossary_service.py +48 -17
- backend/progress_service.py +20 -3
- frontend/camera.js +2 -2
- frontend/feedback.js +1 -1
- frontend/index.html +3 -3
- frontend/insights.js +4 -1
- frontend/overlay.js +47 -38
- frontend/styles.css +360 -99
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.
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
def
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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":
|
| 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.
|
| 72 |
-
corrections = self.
|
| 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 |
-
|
| 86 |
-
|
| 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 |
-
|
| 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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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=`${
|
|
|
|
| 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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
|
| 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">
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
}
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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: #
|
| 3 |
-
--muted: #
|
| 4 |
-
--paper: #
|
| 5 |
-
--white: #
|
| 6 |
-
--green: #
|
| 7 |
-
--lime: #
|
| 8 |
-
--line: #
|
| 9 |
-
--dark: #
|
| 10 |
-
--glass: rgba(
|
| 11 |
-
--shadow: 0
|
| 12 |
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
* { box-sizing: border-box; }
|
| 16 |
html { scroll-behavior: smooth; }
|
| 17 |
-
body {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
button, select, textarea { font: inherit; }
|
| 19 |
button { cursor: pointer; }
|
| 20 |
|
| 21 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
.mark
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
.trust-note
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
.viewport video { position: absolute; width: 100%; height: 100%; object-fit: contain; }
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
.document-scroller[hidden] { display: none; }
|
| 59 |
.document-scroller::-webkit-scrollbar { width: 11px; height: 11px; }
|
| 60 |
-
.document-scroller::-webkit-scrollbar-track { background:
|
| 61 |
-
.document-scroller::-webkit-scrollbar-thumb { border: 3px solid
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
.term-marker:active { cursor: grabbing; }
|
| 68 |
-
.term-marker span {
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
@keyframes marker-in { from { opacity: 0; transform: scale(.4); } }
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 75 |
-
.callout-line { align-self: stretch; width: 1px; min-height: 20px; background: rgba(255,255,255,.
|
| 76 |
-
.popover-heading strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
| 77 |
-
.popover-heading small { color:
|
| 78 |
-
.term-popover p { margin: 11px 0; color: #
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
.selection-hint[hidden] { display: none; }
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 91 |
-
|
| 92 |
-
.corner { position: absolute; width: 34px; height: 34px; border-color: var(--lime); border-style: solid; opacity: .
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
.empty-state.hidden { display: none; }
|
| 99 |
-
.empty-state span { max-width: 250px; color:
|
| 100 |
.lens { margin-bottom: 8px; color: var(--lime); font-size: 50px; }
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
.empty-state .primary { margin-top: 14px; }
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
.viewport.active .scan-line { display: block; }
|
| 106 |
-
@keyframes scan { 0%, 100% { top: 12%; opacity: .
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: #
|
| 111 |
-
.status-pill.live i { background: var(--lime); box-shadow: 0 0 8px var(--lime); }
|
| 112 |
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
.details-toggle i { font-style: normal; transition: transform .3s ease; }
|
| 115 |
.details-open .details-toggle i { transform: rotate(180deg); }
|
| 116 |
|
| 117 |
-
.control-panel {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
.details-open .control-panel { max-height: 740px; padding: 26px 18px calc(20px + var(--safe-bottom)); opacity: 1; transform: translateY(0); }
|
| 119 |
-
.details-close {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 122 |
-
.panel-heading > span { flex: 0 0 auto; padding: 6px 9px; background:
|
| 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:
|
| 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 |
-
|
| 133 |
-
.instruction-card
|
|
|
|
| 134 |
.instruction-card p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
| 135 |
-
|
| 136 |
-
.analysis-progress
|
|
|
|
| 137 |
@keyframes progress-spin { to { transform: rotate(360deg); } }
|
| 138 |
-
.empty-result { padding: 14px; border: 1px dashed
|
| 139 |
-
|
|
|
|
| 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 {
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
.panel-actions { display: grid; gap: 8px; }
|
| 146 |
-
.panel-actions button:disabled { opacity: .
|
| 147 |
-
.panel-actions .secondary {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
.trust-row { display: grid; grid-template-columns: 1fr 1fr; margin-top: 18px; overflow: hidden; border: 1px solid
|
| 150 |
-
.trust-row div { display: flex; flex-direction: column; padding: 15px; border-right: 1px solid
|
| 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(--
|
| 154 |
.trust-row span { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
| 155 |
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
.modal.open { display: grid; }
|
| 158 |
-
.definition-card, .admin-card, .insight-card {
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
#correctionForm { margin-top: 15px; }
|
| 171 |
-
.feedback-message { color: var(--
|
| 172 |
-
|
|
|
|
| 173 |
.insight-card section { padding: 13px 0; border-top: 1px solid var(--line); }
|
| 174 |
-
.insight-card section small { color: var(--
|
| 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 {
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: #
|
| 185 |
.muted { color: var(--muted); }
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
+
|