Gehan77 commited on
Commit
2f80eea
·
verified ·
1 Parent(s): 33cb9fe

Run CV matcher entirely in the browser for free hosting

Browse files
Files changed (4) hide show
  1. README.md +8 -23
  2. app.js +261 -0
  3. index.html +84 -17
  4. style.css +86 -21
README.md CHANGED
@@ -1,30 +1,15 @@
1
  ---
2
- title: Sri Lankan Job-Resume Matcher
3
  emoji: "📄"
4
- colorFrom: blue
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 5.0.0
8
- app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- # Sri Lankan Job-Resume Matcher
13
 
14
- This Space hosts a fine-tuned Sentence-Transformers Cross-Encoder for estimating the relevance of a candidate profile to a job description. Upload a PDF, DOCX or TXT CV to pre-fill the candidate profile, then review the extracted fields before scoring.
15
 
16
- The model was trained with job-relevant fields such as role, seniority, experience, industry, education, skills, summaries, responsibilities and requirements. Candidate names, phone numbers, addresses and location fields were excluded from matching features.
17
-
18
- ## Model information
19
-
20
- - Base model: `cross-encoder/ms-marco-MiniLM-L6-v2`
21
- - Training pairs: 120,000
22
- - Decision threshold: `0.4399277865886688` (selected using validation F1)
23
- - Reported test F1: `0.7957`
24
- - Reported test ROC-AUC: `0.9098`
25
-
26
- ## Intended use and limitations
27
-
28
- Use the score to help rank or review applications. It must not be used as the sole basis for hiring, rejection, or any other high-impact employment decision. Review the original CV and job requirements, check for missing or inaccurate information, and apply human judgment and applicable employment law.
29
-
30
- The training data and evaluation data are synthetic or task-specific as supplied with the model package; performance on real-world CVs and jobs may differ. The model does not verify qualifications, identity, work authorization or references.
 
1
  ---
2
+ title: Free Browser CV Matcher
3
  emoji: "📄"
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: static
7
+ app_file: index.html
 
8
  pinned: false
9
  ---
10
 
11
+ # Free browser-based CV matcher
12
 
13
+ This is a static Hugging Face Space. CV text extraction and model scoring run in the visitor's browser, so the Space does not require paid CPU hosting. The ONNX model is hosted in the public [`Gehan77/cv-match-browser`](https://huggingface.co/Gehan77/cv-match-browser) model repository and is cached by the browser after the first load.
14
 
15
+ The result is an assistive ranking signal only. Review the original CV and job requirements before making any hiring decision.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AutoModelForSequenceClassification,
3
+ AutoTokenizer,
4
+ } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm";
5
+ import * as pdfjsLib from "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.min.mjs";
6
+
7
+ const MODEL_ID = "Gehan77/cv-match-browser";
8
+ const DECISION_THRESHOLD = 0.4399277865886688;
9
+ pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.worker.min.mjs";
10
+
11
+ const $ = (id) => document.getElementById(id);
12
+ const cvFile = $("cvFile");
13
+ const dropzone = $("dropzone");
14
+ const fileLabel = $("fileLabel");
15
+ const extractButton = $("extractButton");
16
+ const scoreButton = $("scoreButton");
17
+ const status = $("status");
18
+ const preview = $("preview");
19
+ const result = $("result");
20
+ let selectedFile = null;
21
+ let modelPromise = null;
22
+
23
+ function setStatus(message, type = "") {
24
+ status.textContent = message;
25
+ status.className = `status ${type}`.trim();
26
+ }
27
+
28
+ function clean(value, fallback = "Not specified") {
29
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
30
+ return text || fallback;
31
+ }
32
+
33
+ function cleanList(value) {
34
+ const text = clean(value);
35
+ if (text === "Not specified") return text;
36
+ const items = text.split(/[|,;\n]+/).map((item) => item.trim()).filter(Boolean);
37
+ return items.length ? items.join(", ") : "Not specified";
38
+ }
39
+
40
+ const headerRules = [
41
+ ["summary", /^(?:professional\s+)?summary$|^profile$|^about\s+me$|^objective$|^career\s+objective$/i],
42
+ ["experience", /^(?:professional\s+)?(?:work\s+)?experience$|^employment\s+history$|^career\s+history$/i],
43
+ ["education", /^education(?:al\s+background)?$|^academic\s+qualifications?$/i],
44
+ ["skills", /^(?:technical\s+|core\s+|key\s+|professional\s+)?skills?$|^competenc(?:y|ies)$|^technologies$/i],
45
+ ];
46
+
47
+ function headerName(line) {
48
+ const candidate = line.replace(/[#:|\-–—]+$/g, "").trim();
49
+ if (candidate.length > 45) return null;
50
+ return headerRules.find(([, pattern]) => pattern.test(candidate))?.[0] ?? null;
51
+ }
52
+
53
+ function parseSections(text) {
54
+ const sections = { other: [] };
55
+ let current = "other";
56
+ for (const rawLine of text.split(/\r?\n/)) {
57
+ const line = rawLine.replace(/^[\s•*\-–—]+/, "").trim();
58
+ if (!line) continue;
59
+ const header = headerName(line);
60
+ if (header) {
61
+ current = header;
62
+ sections[current] ??= [];
63
+ continue;
64
+ }
65
+ sections[current] ??= [];
66
+ sections[current].push(line);
67
+ }
68
+ return sections;
69
+ }
70
+
71
+ function firstNonContactLine(text) {
72
+ for (const rawLine of text.split(/\r?\n/).slice(0, 12)) {
73
+ const line = rawLine.replace(/^[\s•*\-–—]+/, "").trim();
74
+ if (!line || headerName(line)) continue;
75
+ if (/@|https?:\/\/|www\.|(?:\+?\d[\d ()-]{7,})/i.test(line)) continue;
76
+ if (line.split(/\s+/).length <= 10) return line;
77
+ }
78
+ return "";
79
+ }
80
+
81
+ function sectionText(section = [], limit = 800) {
82
+ return section.join(" ").trim().slice(0, limit);
83
+ }
84
+
85
+ function sectionItems(section = [], limit = 1200) {
86
+ return section.filter(Boolean).join("; ").slice(0, limit);
87
+ }
88
+
89
+ function extractCandidateFields(text) {
90
+ const sections = parseSections(text);
91
+ const allText = text.replace(/\s+/g, " ");
92
+ const firstLine = firstNonContactLine(text);
93
+ const roleKeywords = ["developer", "engineer", "manager", "analyst", "designer", "specialist", "officer", "assistant", "consultant", "accountant", "teacher", "intern"];
94
+ let role = "";
95
+ for (const line of (sections.other ?? []).slice(0, 8)) {
96
+ if (line === firstLine) continue;
97
+ if (roleKeywords.some((keyword) => line.toLowerCase().includes(keyword))) {
98
+ role = line;
99
+ break;
100
+ }
101
+ }
102
+ role ||= firstLine;
103
+
104
+ const seniorityMatch = allText.match(/\b(intern|internship|entry[- ]?level|junior|mid[- ]?level|intermediate|senior|lead|principal|manager|director|executive)\b/i);
105
+ const seniority = seniorityMatch ? seniorityMatch[1].replace("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : "";
106
+ const yearsMatch = allText.match(/\b(\d+(?:\.\d+)?)\+?\s*(?:years?|yrs?)\b/i);
107
+ const industryTerms = ["FinTech", "Banking", "Finance", "Software", "Information Technology", "Healthcare", "Education", "Marketing", "Tourism", "Retail", "Logistics", "Construction", "Manufacturing", "Telecommunications", "Insurance", "Hospitality", "Government", "E-commerce"];
108
+ const industry = industryTerms.find((term) => new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(allText)) ?? "";
109
+ const summary = sectionText(sections.summary) || sectionText((sections.other ?? []).slice(1, 5), 900);
110
+
111
+ return {
112
+ candidateRole: role,
113
+ candidateSeniority: seniority,
114
+ yearsExperience: yearsMatch?.[1] ?? "",
115
+ candidateIndustry: industry,
116
+ education: sectionItems(sections.education, 600),
117
+ candidateSkills: sectionItems(sections.skills),
118
+ summary,
119
+ experienceBullets: sectionItems(sections.experience, 1400),
120
+ };
121
+ }
122
+
123
+ function applyCandidateFields(fields) {
124
+ for (const [id, value] of Object.entries(fields)) $(id).value = value;
125
+ }
126
+
127
+ async function extractPdfText(file) {
128
+ const data = new Uint8Array(await file.arrayBuffer());
129
+ const pdf = await pdfjsLib.getDocument({ data }).promise;
130
+ const pages = [];
131
+ for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
132
+ const page = await pdf.getPage(pageNumber);
133
+ const content = await page.getTextContent();
134
+ pages.push(content.items.map((item) => item.str).join(" "));
135
+ }
136
+ return pages.join("\n");
137
+ }
138
+
139
+ async function extractFileText(file) {
140
+ const extension = file.name.split(".").pop().toLowerCase();
141
+ if (extension === "txt") return file.text();
142
+ if (extension === "pdf") return extractPdfText(file);
143
+ if (extension === "docx") {
144
+ if (!window.mammoth) throw new Error("DOCX reader could not be loaded. Try a PDF or TXT file.");
145
+ const result = await window.mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() });
146
+ return result.value;
147
+ }
148
+ throw new Error("Supported CV formats are PDF, DOCX and TXT.");
149
+ }
150
+
151
+ async function parseUploadedCv() {
152
+ if (!selectedFile) return;
153
+ extractButton.disabled = true;
154
+ setStatus("Reading your CV in the browser…");
155
+ try {
156
+ const text = (await extractFileText(selectedFile)).replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
157
+ if (!text) throw new Error("No selectable text found. This may be a scanned PDF.");
158
+ preview.value = `${text.slice(0, 2500)}${text.length > 2500 ? "…" : ""}`;
159
+ const fields = extractCandidateFields(text);
160
+ applyCandidateFields(fields);
161
+ const filled = Object.values(fields).filter(Boolean).length;
162
+ setStatus(`Extracted ${filled} of 8 candidate fields. Please review them before scoring.`, "success");
163
+ } catch (error) {
164
+ setStatus(error.message || "Could not read this file.", "error");
165
+ } finally {
166
+ extractButton.disabled = false;
167
+ }
168
+ }
169
+
170
+ function setSelectedFile(file) {
171
+ if (!file) return;
172
+ selectedFile = file;
173
+ fileLabel.textContent = file.name;
174
+ extractButton.disabled = false;
175
+ setStatus("File selected. Click Extract CV details to fill the form.");
176
+ }
177
+
178
+ function buildJobText() {
179
+ return clean(`
180
+ Job Title: ${clean($("jobTitle").value)}.
181
+ Required Seniority: ${clean($("jobSeniority").value)}.
182
+ Industry: ${clean($("jobIndustry").value)}.
183
+ Must-Have Skills: ${cleanList($("mustHaveSkills").value)}.
184
+ Nice-to-Have Skills: ${cleanList($("niceToHaveSkills").value)}.
185
+ Job Description: ${clean($("jobDescription").value)}.
186
+ Responsibilities: ${cleanList($("responsibilities").value)}.
187
+ Requirements: ${cleanList($("requirements").value)}.
188
+ `);
189
+ }
190
+
191
+ function buildResumeText() {
192
+ return clean(`
193
+ Candidate Role: ${clean($("candidateRole").value)}.
194
+ Seniority Level: ${clean($("candidateSeniority").value)}.
195
+ Years of Experience: ${clean($("yearsExperience").value)}.
196
+ Industry Experience: ${clean($("candidateIndustry").value)}.
197
+ Education: ${clean($("education").value)}.
198
+ Skills: ${cleanList($("candidateSkills").value)}.
199
+ Professional Summary: ${clean($("summary").value)}.
200
+ Experience Highlights: ${cleanList($("experienceBullets").value)}.
201
+ `);
202
+ }
203
+
204
+ async function loadBrowserModel() {
205
+ if (!modelPromise) {
206
+ modelPromise = Promise.all([
207
+ AutoTokenizer.from_pretrained(MODEL_ID),
208
+ AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: "fp32" }),
209
+ ]);
210
+ }
211
+ return modelPromise;
212
+ }
213
+
214
+ async function scoreMatch() {
215
+ scoreButton.disabled = true;
216
+ scoreButton.textContent = "Loading model…";
217
+ setStatus("The browser model is loading. The first load may take a minute.");
218
+ result.hidden = true;
219
+ try {
220
+ const [tokenizer, model] = await loadBrowserModel();
221
+ setStatus("Scoring the job and candidate profile…");
222
+ const inputs = await tokenizer(buildJobText(), {
223
+ text_pair: buildResumeText(),
224
+ padding: "max_length",
225
+ truncation: true,
226
+ max_length: 256,
227
+ });
228
+ const output = await model(inputs);
229
+ const logit = Number(output.logits.data[0]);
230
+ const probability = 1 / (1 + Math.exp(-logit));
231
+ const verdict = probability >= DECISION_THRESHOLD ? "Suitable match" : "Needs review";
232
+ const percent = (probability * 100).toFixed(1);
233
+ result.innerHTML = `
234
+ <div class="result-top">
235
+ <div><h3>${verdict}</h3><p>Model-estimated relevance for this job and candidate profile.</p></div>
236
+ <div class="score-number">${percent}%</div>
237
+ </div>
238
+ <div class="score-bar"><span style="width:${Math.max(2, probability * 100)}%"></span></div>
239
+ <p>Decision threshold: ${(DECISION_THRESHOLD * 100).toFixed(1)}%</p>
240
+ <p class="result-note">This is an assistive ranking signal only. Review the original CV and job requirements before making any hiring decision.</p>
241
+ `;
242
+ result.hidden = false;
243
+ setStatus("Match scored in your browser.", "success");
244
+ } catch (error) {
245
+ setStatus(`Could not score this profile: ${error.message || error}`, "error");
246
+ } finally {
247
+ scoreButton.disabled = false;
248
+ scoreButton.innerHTML = 'Score match <span>→</span>';
249
+ }
250
+ }
251
+
252
+ cvFile.addEventListener("change", (event) => setSelectedFile(event.target.files[0]));
253
+ extractButton.addEventListener("click", parseUploadedCv);
254
+ scoreButton.addEventListener("click", scoreMatch);
255
+ dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("dragging"); });
256
+ dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragging"));
257
+ dropzone.addEventListener("drop", (event) => {
258
+ event.preventDefault();
259
+ dropzone.classList.remove("dragging");
260
+ setSelectedFile(event.dataTransfer.files[0]);
261
+ });
index.html CHANGED
@@ -1,19 +1,86 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="theme-color" content="#0b1220" />
7
+ <title>CV Match - Free Browser App</title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ <script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
10
+ </head>
11
+ <body>
12
+ <main class="shell">
13
+ <header class="hero">
14
+ <div class="eyebrow"><span class="pulse"></span> Free browser-based CV matcher</div>
15
+ <h1>Find the right match<br /><em>with confidence.</em></h1>
16
+ <p class="hero-copy">Upload a CV, review the extracted details, and compare it with a job description using your trained model.</p>
17
+ <div class="privacy-note"><span>🔒</span> Your CV is processed in this browser. It is not uploaded to an application server.</div>
18
+ </header>
19
+
20
+ <section class="workflow">
21
+ <form class="panel" id="jobPanel">
22
+ <div class="panel-heading">
23
+ <span class="step-number">01</span>
24
+ <div><p class="overline">Start here</p><h2>Job details</h2></div>
25
+ </div>
26
+ <p class="panel-intro">Describe the role you are hiring for. More context usually gives a more useful ranking signal.</p>
27
+ <label>Job title<input id="jobTitle" value="Customer Support Specialist" /></label>
28
+ <div class="two-col">
29
+ <label>Required seniority<input id="jobSeniority" value="Junior" /></label>
30
+ <label>Industry<input id="jobIndustry" value="FinTech" /></label>
31
+ </div>
32
+ <label>Must-have skills<input id="mustHaveSkills" placeholder="Python, customer service, CRM" /></label>
33
+ <label>Nice-to-have skills<input id="niceToHaveSkills" placeholder="Separate skills with commas" /></label>
34
+ <label>Job description<textarea id="jobDescription" rows="4" placeholder="What is this person expected to do?"></textarea></label>
35
+ <label>Responsibilities<textarea id="responsibilities" rows="3" placeholder="List the main responsibilities"></textarea></label>
36
+ <label>Requirements<textarea id="requirements" rows="3" placeholder="List the essential requirements"></textarea></label>
37
+ </form>
38
+
39
+ <section class="panel" id="candidatePanel">
40
+ <div class="panel-heading">
41
+ <span class="step-number">02</span>
42
+ <div><p class="overline">Upload and review</p><h2>Candidate CV</h2></div>
43
+ </div>
44
+ <p class="panel-intro">PDF, DOCX and TXT files are supported. The first model download may take a minute and will be cached in your browser.</p>
45
+
46
+ <label class="dropzone" id="dropzone">
47
+ <input id="cvFile" type="file" accept=".pdf,.docx,.txt" />
48
+ <span class="upload-icon">↑</span>
49
+ <strong id="fileLabel">Choose a CV file</strong>
50
+ <small>or drag and drop it here</small>
51
+ </label>
52
+ <button class="button button-secondary" id="extractButton" type="button" disabled>Extract CV details</button>
53
+ <div class="status" id="status" role="status">Waiting for a CV upload.</div>
54
+
55
+ <label>Extracted text preview<textarea id="preview" rows="5" readonly placeholder="Your CV text preview will appear here."></textarea></label>
56
+
57
+ <div class="subheading"><span>Candidate details</span><small>Review before scoring</small></div>
58
+ <label>Candidate role<input id="candidateRole" /></label>
59
+ <div class="two-col">
60
+ <label>Seniority<input id="candidateSeniority" /></label>
61
+ <label>Years of experience<input id="yearsExperience" /></label>
62
+ </div>
63
+ <label>Industry experience<input id="candidateIndustry" /></label>
64
+ <label>Education<input id="education" /></label>
65
+ <label>Skills<input id="candidateSkills" placeholder="Separate skills with commas" /></label>
66
+ <label>Professional summary<textarea id="summary" rows="3"></textarea></label>
67
+ <label>Experience highlights<textarea id="experienceBullets" rows="4"></textarea></label>
68
+ </section>
69
+ </section>
70
+
71
+ <section class="score-section">
72
+ <div class="score-heading">
73
+ <div><p class="overline">Final step</p><h2>Score the match</h2></div>
74
+ <button class="button button-primary" id="scoreButton" type="button">Score match <span>→</span></button>
75
+ </div>
76
+ <div class="result" id="result" hidden></div>
77
+ </section>
78
+
79
+ <footer>
80
+ <span>Assistive ranking only - always review the original CV and job requirements.</span>
81
+ <a href="https://huggingface.co/Gehan77/cv-match-browser" target="_blank" rel="noreferrer">Model card ↗</a>
82
+ </footer>
83
+ </main>
84
+ <script type="module" src="app.js"></script>
85
+ </body>
86
  </html>
style.css CHANGED
@@ -1,28 +1,93 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
 
 
 
 
 
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #09101d;
4
+ --panel: rgba(18, 29, 49, 0.88);
5
+ --panel-2: #121e32;
6
+ --line: rgba(145, 167, 204, 0.18);
7
+ --muted: #93a3bd;
8
+ --text: #f5f7fb;
9
+ --accent: #7c8cff;
10
+ --accent-2: #5eead4;
11
+ --danger: #fb7185;
12
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
  }
14
 
15
+ * { box-sizing: border-box; }
 
 
 
16
 
17
+ body {
18
+ margin: 0;
19
+ min-width: 320px;
20
+ background: radial-gradient(circle at 50% -10%, #172949 0, transparent 46%), var(--bg);
21
+ color: var(--text);
22
  }
23
 
24
+ button, input, textarea { font: inherit; }
25
+ button { cursor: pointer; }
26
+
27
+ .shell { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 52px 0 30px; }
28
+ .hero { padding: 24px 4px 42px; max-width: 730px; }
29
+ .eyebrow, .overline { color: var(--accent-2); text-transform: uppercase; letter-spacing: .13em; font-size: .72rem; font-weight: 800; }
30
+ .eyebrow { display: flex; align-items: center; gap: 9px; }
31
+ .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-2); box-shadow: 0 0 0 7px rgba(94, 234, 212, .12); }
32
+ h1 { margin: 18px 0 14px; font-size: clamp(2.6rem, 6vw, 5.4rem); line-height: .98; letter-spacing: -.065em; }
33
+ h1 em { color: var(--accent); font-style: normal; }
34
+ .hero-copy { max-width: 630px; color: var(--muted); font-size: 1.05rem; line-height: 1.7; }
35
+ .privacy-note { display: inline-flex; gap: 9px; align-items: center; margin-top: 19px; padding: 10px 13px; border: 1px solid rgba(94, 234, 212, .22); border-radius: 10px; color: #bdeee6; background: rgba(94, 234, 212, .06); font-size: .84rem; }
36
 
37
+ .workflow { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: start; }
38
+ .panel, .score-section { border: 1px solid var(--line); border-radius: 20px; background: linear-gradient(145deg, rgba(22, 35, 58, .95), rgba(13, 22, 38, .92)); box-shadow: 0 20px 60px rgba(0, 0, 0, .18); }
39
+ .panel { padding: 25px; }
40
+ .panel-heading, .score-heading { display: flex; align-items: center; gap: 13px; }
41
+ .step-number { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; background: rgba(124, 140, 255, .14); color: #b3bcff; font-size: .77rem; font-weight: 900; }
42
+ .overline { margin: 0 0 4px; font-size: .65rem; }
43
+ h2 { margin: 0; font-size: 1.55rem; letter-spacing: -.035em; }
44
+ .panel-intro { color: var(--muted); line-height: 1.55; font-size: .87rem; margin: 18px 0 22px; }
45
+ label { display: block; color: #c5d0e3; font-size: .78rem; font-weight: 700; margin-top: 15px; }
46
+ input, textarea { display: block; width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; outline: none; color: var(--text); background: rgba(5, 12, 24, .62); transition: border-color .2s, box-shadow .2s; }
47
+ textarea { resize: vertical; line-height: 1.45; }
48
+ input:focus, textarea:focus { border-color: rgba(124, 140, 255, .8); box-shadow: 0 0 0 3px rgba(124, 140, 255, .12); }
49
+ input::placeholder, textarea::placeholder { color: #66758f; }
50
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
51
+
52
+ .dropzone { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 142px; margin-top: 5px; border: 1px dashed rgba(124, 140, 255, .65); border-radius: 14px; color: #dae0ff; background: rgba(124, 140, 255, .07); text-align: center; cursor: pointer; transition: background .2s, transform .2s; }
53
+ .dropzone:hover, .dropzone.dragging { background: rgba(124, 140, 255, .14); transform: translateY(-1px); }
54
+ .dropzone input { display: none; }
55
+ .upload-icon { display: grid; place-items: center; width: 37px; height: 37px; margin-bottom: 8px; border-radius: 50%; background: var(--accent); color: #0b1220; font-size: 1.4rem; font-weight: 800; }
56
+ .dropzone small { margin-top: 6px; color: var(--muted); font-weight: 500; }
57
+ .button { border: 0; border-radius: 11px; padding: 12px 17px; color: #f8fbff; font-weight: 800; transition: transform .2s, filter .2s, opacity .2s; }
58
+ .button:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.08); }
59
+ .button:disabled { cursor: not-allowed; opacity: .45; }
60
+ .button-secondary { width: 100%; margin-top: 12px; border: 1px solid var(--line); background: rgba(255, 255, 255, .06); }
61
+ .button-primary { background: linear-gradient(120deg, #6d7df8, #8b5cf6); box-shadow: 0 10px 28px rgba(109, 125, 248, .25); }
62
+ .button-primary span { margin-left: 7px; font-size: 1.1rem; }
63
+ .status { min-height: 23px; margin-top: 11px; color: var(--muted); font-size: .79rem; line-height: 1.45; }
64
+ .status.success { color: #8de9d8; }
65
+ .status.error { color: #fda4af; }
66
+ .subheading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 21px; padding-top: 19px; border-top: 1px solid var(--line); color: #e7ebf6; font-size: .83rem; font-weight: 800; }
67
+ .subheading small { color: var(--muted); font-size: .72rem; font-weight: 500; }
68
+
69
+ .score-section { margin-top: 20px; padding: 25px; }
70
+ .score-heading { justify-content: space-between; }
71
+ .result { margin-top: 23px; padding: 22px; border: 1px solid rgba(124, 140, 255, .3); border-radius: 15px; background: rgba(124, 140, 255, .08); }
72
+ .result-top { display: flex; justify-content: space-between; align-items: start; gap: 20px; }
73
+ .result h3 { margin: 0 0 8px; font-size: 1.35rem; }
74
+ .result p { color: var(--muted); line-height: 1.55; margin: 6px 0; font-size: .88rem; }
75
+ .score-number { color: #b8c0ff; font-size: 2.1rem; font-weight: 900; white-space: nowrap; }
76
+ .score-bar { height: 7px; margin: 18px 0 12px; overflow: hidden; border-radius: 99px; background: rgba(255,255,255,.09); }
77
+ .score-bar span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
78
+ .result-note { color: #aebbd0 !important; font-size: .77rem !important; }
79
+ footer { display: flex; justify-content: space-between; gap: 15px; padding: 24px 4px 0; color: #65758f; font-size: .74rem; }
80
+ footer a { color: #9ca9ff; text-decoration: none; }
81
+
82
+ @media (max-width: 800px) {
83
+ .shell { width: min(100% - 24px, 620px); padding-top: 28px; }
84
+ .workflow { grid-template-columns: 1fr; }
85
+ .hero { padding-bottom: 27px; }
86
+ h1 { font-size: clamp(2.7rem, 13vw, 4.3rem); }
87
+ }
88
+ @media (max-width: 500px) {
89
+ .panel, .score-section { padding: 18px; border-radius: 15px; }
90
+ .two-col, .score-heading, .result-top, footer { grid-template-columns: 1fr; display: grid; }
91
+ .score-heading { gap: 17px; }
92
+ .button-primary { width: 100%; }
93
  }