PageSourceSearch

https://www.trackply.com/src/js/views/resume-builder.js

js trackply.com collected 2026-10-02 00:57:13 UTC 52,475 bytes, 1,220 lines download raw bytes

1import { getResumes, getResume, saveResume, deleteResume, getProfile } from '../db.js';
2import { formatDate, escapeHtml, svgIcon, uid } from '../utils.js';
3import { canScoreResume, markResumeUsed, savedResumesLimit, isPro } from '../plan.js';
4import { showUpgradeModal } from '../components/modal.js';
5import { showToast } from '../components/toast.js';
6import { getAccessToken } from '../auth.js';
7import { maybeStartPipelineOnboarding } from '../components/kemba-pipeline-onboarding.js';
8import { markPhase1Completed } from '../phases.js';
9import { t, hasKey } from '../i18n.js';
10
11// ── Module state ─────────────────────────────────────────────────
12let resumes = [];
13let currentResume = null;
14let experiences = [];
15let educations = [];
16let _rbProfile = null;
17
18// Reveal-flow state (Part 9): the resume that was scored + the AI rewrite result.
19let _revealBase    = null;   // resumeData passed to the score screen
20let _revealRewrite = null;   // { rewrittenResume, newScore, changes }
21let _revealOldScore = null;  // the pre-rewrite score (0-100)
22let _lastJobDesc   = '';     // job description from the last scoring run (for rewrite context)
23
24const CIRC = 2 * Math.PI * 54;
25
26// Scores are stored 0-100 internally but displayed on a /10 scale (Part 9).
27function fmt10(n) { return (Math.round((Number(n) || 0) * 10) / 100).toFixed(1); }
28
29function scoreColor(n) {
30  if (n >= 80) return '#22c55e';
31  if (n >= 60) return '#f59e0b';
32  return '#ef4444';
33}
34
35function scoreLabel(n) {
36  if (n >= 80) return t('rb.score_excellent');
37  if (n >= 60) return t('rb.score_good');
38  if (n >= 40) return t('rb.score_needs_work');
39  return t('rb.score_poor');
40}
41
42// ── Entry point ──────────────────────────────────────────────────
43
44export async function renderResumeBuilder(container) {
45  container.innerHTML = `<div class="fade-in" id="rb-root"></div>`;
46  [resumes, _rbProfile] = await Promise.all([getResumes(), getProfile().catch(() => null)]);
47  currentResume = null;
48  experiences = [];
49  educations = [];
50  const root = document.getElementById('rb-root');
51  renderRoot(root);
52
53  // Part 8 hand-off: onboarding's "Tailor an existing Resume" path uploads the
54  // PDF inline, then routes here with the resume + targeting already captured.
55  // Load it straight into the editor and run the (free) scoring automatically so
56  // the user lands on the score screen with no second manual upload.
57  const pre = window.__rbResume;
58  if (pre && (pre.raw_text || '').trim()) {
59    window.__rbResume = null;
60    openEditor(root, null);
61    root.querySelector('#rb-name').value        = pre.name || 'My Resume';
62    root.querySelector('#rb-target-role').value = pre.target_role || '';
63    root.querySelector('#rb-raw-text').value    = pre.raw_text || '';
64    if (pre.location) root.querySelector('#rb-c-location').value = pre.location;
65    // Auto-run scoring (free) → lands on the score screen, ready to "Generate My New Resume".
66    handleSave(root, true);
67    return;
68  }
69
70  // Deep-link entry from the resume-first onboarding: open straight into the
71  // editor in the chosen mode ('upload' = tailor existing, 'scratch' = build new).
72  const intent = window.__rbIntent;
73  if (intent) {
74    window.__rbIntent = null;
75    openEditor(root, null);
76    if (intent === 'upload') {
77      // Pop the PDF picker so they can drop in their existing CV right away.
78      setTimeout(() => root.querySelector('#rb-pdf-upload')?.click(), 350);
79    }
80  }
81}
82
83function renderRoot(root) {
84  root.innerHTML = htmlScreenA() + htmlScreenB()
85    + `<div id="rb-screen-c" class="hidden"></div>`
86    + `<div id="rb-screen-d" class="hidden"></div>`;
87  bindScreenA(root);
88  bindScreenB(root);
89  showScreen('a', root);
90}
91
92// ── Screen A: Resume list ────────────────────────────────────────
93
94function htmlScreenA() {
95  return `
96    <div id="rb-screen-a" class="hidden">
97      <div class="dash-header">
98        <div>
99          <div class="dash-title">${t('rb.title')}</div>
100          <div class="dash-subtitle">${t('rb.subtitle')}</div>
101        </div>
102        <button class="btn btn-primary" id="rb-new-btn">
103          ${svgIcon('plus', '#000', 16)} ${t('rb.new')}
104        </button>
105      </div>
106      <div id="rb-list">${htmlResumeList()}</div>
107    </div>
108  `;
109}
110
111function htmlResumeList() {
112  if (!resumes.length) {
113    return `
114      <div class="empty-state">
115        <div class="empty-icon">${svgIcon('file-text', 'currentColor', 32)}</div>
116        <div class="empty-title">${t('rb.empty_title')}</div>
117        <div class="empty-desc">${t('rb.empty_desc')}</div>
118      </div>
119    `;
120  }
121  return `<div class="rb-resume-grid">${resumes.map(htmlResumeCard).join('')}</div>`;
122}
123
124function htmlResumeCard(r) {
125  const s = r.ai_score;
126  const badge = s != null
127    ? `<span class="rb-score-badge" style="background:${scoreColor(s)}22;color:${scoreColor(s)};border-color:${scoreColor(s)}55">${fmt10(s)}</span>`
128    : `<span class="rb-score-badge rb-score-none">—</span>`;
129  return `
130    <div class="app-card rb-resume-card" data-id="${r.id}" style="cursor:pointer">
131      <div class="card-header">
132        <div>
133          <div class="card-company">${escapeHtml(r.name || t('rb.untitled'))}</div>
134          <div class="card-title">${escapeHtml(r.target_role || t('rb.no_target'))}</div>
135        </div>
136        <div class="card-badges">${badge}</div>
137      </div>
138      <div class="card-meta">
139        <span class="card-meta-item">${svgIcon('calendar', 'currentColor', 12)} ${t('common.updated', { date: formatDate(r.updated_at) })}</span>
140      </div>
141      <div class="card-footer" style="justify-content:flex-end">
142        <button class="btn btn-sm btn-secondary" data-edit="${r.id}">
143          ${svgIcon('edit', 'currentColor', 12)} ${t('btn.edit')}
144        </button>
145        <button class="btn btn-sm btn-danger rb-delete-btn" data-id="${r.id}">
146          ${svgIcon('trash', 'currentColor', 12)} ${t('btn.delete')}
147        </button>
148      </div>
149    </div>
150  `;
151}
152
153function bindScreenA(root) {
154  root.querySelector('#rb-new-btn')?.addEventListener('click', () => {
155    const limit = savedResumesLimit();
156    if (resumes.length >= limit) {
157      showUpgradeModal('saved_resumes_limit');
158      return;
159    }
160    openEditor(root, null);
161  });
162
163  root.addEventListener('click', e => {
164    const delBtn  = e.target.closest('.rb-delete-btn');
165    const editBtn = e.target.closest('[data-edit]');
166    const card    = e.target.closest('.rb-resume-card');
167
168    if (delBtn)  { e.stopPropagation(); confirmDelete(root, delBtn.dataset.id);   return; }
169    if (editBtn) { e.stopPropagation(); loadAndEditResume(root, editBtn.dataset.edit); return; }
170    if (card)    { loadAndEditResume(root, card.dataset.id); }
171  });
172}
173
174// ── Screen B: Editor ─────────────────────────────────────────────
175
176function htmlScreenB() {
177  return `
178    <div id="rb-screen-b" class="hidden">
179      <div class="view-header">
180        <button class="back-btn" id="rb-back-list" title="Back to resumes">
181          ${svgIcon('arrow-left', 'currentColor', 16)}
182        </button>
183        <h2 class="view-header-title" id="rb-editor-title">${t('rb.new')}</h2>
184      </div>
185
186      <div class="rb-editor-wrap">
187        <div class="form-card" style="max-width:100%">
188
189          <div class="form-section">
190            <div class="form-section-title">${t('rb.details')}</div>
191            <div class="form-row">
192              <div class="form-group">
193                <label class="form-label" for="rb-name">${t('rb.name')}</label>
194                <input type="text" id="rb-name" class="form-input" placeholder="e.g. My SWE Resume">
195              </div>
196              <div class="form-group">
197                <label class="form-label" for="rb-target-role">${t('rb.target_role')}</label>
198                <input type="text" id="rb-target-role" class="form-input" placeholder="e.g. Software Engineer">
199              </div>
200            </div>
201          </div>
202
203          <div class="form-section">
204            <div class="form-section-title">
205              ${t('rb.jd')}
206              <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-muted);font-size:12px"> ${t('rb.jd_hint')}</span>
207            </div>
208            <div class="form-row single">
209              <div class="form-group">
210                <textarea id="rb-job-desc" class="form-textarea" rows="4"
211                  placeholder="${t('rb.jd_ph')}"></textarea>
212              </div>
213            </div>
214          </div>
215
216          <div class="form-section">
217            <div class="form-section-title">${t('rb.full_text')}</div>
218            <div class="rb-upload-row">
219              <label class="btn btn-secondary btn-sm rb-upload-label" for="rb-pdf-upload">
220                ${svgIcon('file-text', 'currentColor', 13)} ${t('rb.upload_pdf')}
221              </label>
222              <input type="file" id="rb-pdf-upload" accept=".pdf" style="display:none">
223              <span id="rb-upload-status" class="rb-upload-status"></span>
224            </div>
225            <div class="form-row single">
226              <div class="form-group">
227                <textarea id="rb-raw-text" class="form-textarea" rows="10"
228                  placeholder="${t('rb.raw_ph')}"></textarea>
229              </div>
230            </div>
231          </div>
232
233          <div class="form-section" style="margin-bottom:0">
234            <button class="rb-section-toggle" id="rb-toggle-structured" type="button">
235              <span class="rb-toggle-icon">${svgIcon('plus', 'var(--accent)', 14)}</span>
236              <span>${t('rb.build_sections')}</span>
237              <span class="rb-toggle-chevron">${svgIcon('sort-desc', 'currentColor', 13)}</span>
238            </button>
239            <div id="rb-structured" class="hidden">
240              ${htmlStructured()}
241            </div>
242          </div>
243
244          <div id="rb-error" class="rb-error hidden"></div>
245
246          <div class="rb-action-bar">
247            <button class="btn btn-secondary" id="rb-save-draft">${t('rb.save_draft')}</button>
248            <button class="btn btn-primary rb-score-btn" id="rb-save-score">
249              ${t('rb.score_btn')}
250            </button>
251          </div>
252
253        </div>
254      </div>
255    </div>
256  `;
257}
258
259function htmlStructured() {
260  return `
261    <div class="rb-structured-inner">
262
263      <div class="form-section">
264        <div class="form-section-title">${t('rb.contact')}</div>
265        <div class="form-row">
266          <div class="form-group">
267            <label class="form-label">${t('label.full_name')}</label>
268            <input type="text" id="rb-c-name" class="form-input" placeholder="Jane Doe">
269          </div>
270          <div class="form-group">
271            <label class="form-label">${t('label.email')}</label>
272            <input type="text" id="rb-c-email" class="form-input" placeholder="[email protected]">
273          </div>
274        </div>
275        <div class="form-row triple">
276          <div class="form-group">
277            <label class="form-label">${t('label.phone')}</label>
278            <input type="text" id="rb-c-phone" class="form-input" placeholder="+1 (555) 000-0000">
279          </div>
280          <div class="form-group">
281            <label class="form-label">${t('label.linkedin')}</label>
282            <input type="text" id="rb-c-linkedin" class="form-input" placeholder="linkedin.com/in/…">
283          </div>
284          <div class="form-group">
285            <label class="form-label">${t('label.location')}</label>
286            <input type="text" id="rb-c-location" class="form-input" placeholder="New York, NY">
287          </div>
288        </div>
289      </div>
290
291      <div class="form-section">
292        <div class="form-section-title">${t('rb.summary')}</div>
293        <div class="form-row single">
294          <div class="form-group">
295            <textarea id="rb-summary" class="form-textarea" rows="4"
296              placeholder="${t('rb.summary_ph')}"></textarea>
297          </div>
298        </div>
299      </div>
300
301      <div class="form-section">
302        <div class="form-section-title">${t('rb.experience')}</div>
303        <div id="rb-exp-list"></div>
304        <button class="btn btn-ghost btn-sm rb-add-row-btn" id="rb-add-exp" type="button">
305          ${svgIcon('plus', 'currentColor', 12)} ${t('rb.add_position')}
306        </button>
307      </div>
308
309      <div class="form-section">
310        <div class="form-section-title">${t('rb.education')}</div>
311        <div id="rb-edu-list"></div>
312        <button class="btn btn-ghost btn-sm rb-add-row-btn" id="rb-add-edu" type="button">
313          ${svgIcon('plus', 'currentColor', 12)} ${t('rb.add_education')}
314        </button>
315      </div>
316
317      <div class="form-section" style="margin-bottom:0">
318        <div class="form-section-title">${t('rb.skills_certs')}</div>
319        <div class="form-row">
320          <div class="form-group">
321            <label class="form-label">${t('rb.skills')} <small style="color:var(--text-muted);font-weight:400">${t('rb.comma_sep')}</small></label>
322            <input type="text" id="rb-skills" class="form-input" placeholder="JavaScript, React, Node.js, SQL…">
323          </div>
324          <div class="form-group">
325            <label class="form-label">${t('rb.certs')} <small style="color:var(--text-muted);font-weight:400">${t('rb.comma_sep')}</small></label>
326            <input type="text" id="rb-certs" class="form-input" placeholder="AWS Solutions Architect, PMP…">
327          </div>
328        </div>
329      </div>
330
331    </div>
332  `;
333}
334
335function htmlExpRow(i, exp = {}) {
336  return `
337    <div class="rb-repeat-group" data-exp="${i}">
338      <div class="rb-repeat-header">
339        <span class="rb-repeat-label">${t('rb.position_n', { n: i + 1 })}</span>
340        <button class="btn-icon rb-rm-exp" data-idx="${i}" type="button" title="Remove">
341          ${svgIcon('x', 'currentColor', 12)}
342        </button>
343      </div>
344      <div class="form-row">
345        <div class="form-group">
346          <label class="form-label">${t('label.company')}</label>
347          <input class="form-input rb-exp-company" placeholder="Acme Corp" value="${escapeHtml(exp.company || '')}">
348        </div>
349        <div class="form-group">
350          <label class="form-label">${t('label.position')}</label>
351          <input class="form-input rb-exp-title" placeholder="Senior Engineer" value="${escapeHtml(exp.title || '')}">
352        </div>
353      </div>
354      <div class="form-row single">
355        <div class="form-group">
356          <label class="form-label">${t('rb.dates')}</label>
357          <input class="form-input rb-exp-dates" placeholder="Jan 2021 – Present" value="${escapeHtml(exp.dates || '')}">
358        </div>
359      </div>
360      <div class="form-row single">
361        <div class="form-group">
362          <label class="form-label">${t('rb.bullets')}</label>
363          <textarea class="form-textarea rb-exp-bullets" rows="3"
364            placeholder="• Built a scalable microservices platform…">${escapeHtml(exp.bullets || '')}</textarea>
365        </div>
366      </div>
367    </div>
368  `;
369}
370
371function htmlEduRow(i, edu = {}) {
372  return `
373    <div class="rb-repeat-group" data-edu="${i}">
374      <div class="rb-repeat-header">
375        <span class="rb-repeat-label">${t('rb.education_n', { n: i + 1 })}</span>
376        <button class="btn-icon rb-rm-edu" data-idx="${i}" type="button" title="Remove">
377          ${svgIcon('x', 'currentColor', 12)}
378        </button>
379      </div>
380      <div class="form-row triple">
381        <div class="form-group">
382          <label class="form-label">${t('rb.school')}</label>
383          <input class="form-input rb-edu-school" placeholder="MIT" value="${escapeHtml(edu.school || '')}">
384        </div>
385        <div class="form-group">
386          <label class="form-label">${t('rb.degree')}</label>
387          <input class="form-input rb-edu-degree" placeholder="B.S. Computer Science" value="${escapeHtml(edu.degree || '')}">
388        </div>
389        <div class="form-group">
390          <label class="form-label">${t('rb.dates')}</label>
391          <input class="form-input rb-edu-dates" placeholder="2017 – 2021" value="${escapeHtml(edu.dates || '')}">
392        </div>
393      </div>
394    </div>
395  `;
396}
397
398function bindScreenB(root) {
399  root.querySelector('#rb-back-list')?.addEventListener('click', () => showScreen('a', root));
400
401  root.querySelector('#rb-toggle-structured')?.addEventListener('click', () => {
402    root.querySelector('#rb-structured')?.classList.toggle('hidden');
403    root.querySelector('#rb-toggle-structured')?.classList.toggle('open');
404  });
405
406  root.querySelector('#rb-add-exp')?.addEventListener('click', () => {
407    syncExpState(root);
408    experiences.push({});
409    refreshExpList(root);
410  });
411
412  root.querySelector('#rb-add-edu')?.addEventListener('click', () => {
413    syncEduState(root);
414    educations.push({});
415    refreshEduList(root);
416  });
417
418  root.querySelector('#rb-exp-list')?.addEventListener('click', e => {
419    const rm = e.target.closest('.rb-rm-exp');
420    if (!rm) return;
421    syncExpState(root);
422    experiences.splice(parseInt(rm.dataset.idx), 1);
423    refreshExpList(root);
424  });
425
426  root.querySelector('#rb-edu-list')?.addEventListener('click', e => {
427    const rm = e.target.closest('.rb-rm-edu');
428    if (!rm) return;
429    syncEduState(root);
430    educations.splice(parseInt(rm.dataset.idx), 1);
431    refreshEduList(root);
432  });
433
434  root.querySelector('#rb-save-draft')?.addEventListener('click', () => handleSave(root, false));
435  root.querySelector('#rb-save-score')?.addEventListener('click', () => handleSave(root, true));
436
437  // PDF upload → extract text into the textarea
438  root.querySelector('#rb-pdf-upload')?.addEventListener('change', async e => {
439    const file      = e.target.files?.[0];
440    const statusEl  = root.querySelector('#rb-upload-status');
441    if (!file) return;
442    if (statusEl) statusEl.textContent = 'Extracting…';
443    try {
444      const text = await extractPDFText(file);
445      root.querySelector('#rb-raw-text').value = text;
446      if (statusEl) { statusEl.textContent = '✓ Resume extracted'; statusEl.style.color = 'var(--s-offer)'; }
447    } catch (err) {
448      if (statusEl) { statusEl.textContent = '✗ ' + err.message; statusEl.style.color = 'var(--s-rejected)'; }
449    }
450  });
451}
452
453function refreshExpList(root) {
454  const list = root.querySelector('#rb-exp-list');
455  if (list) list.innerHTML = experiences.map((e, i) => htmlExpRow(i, e)).join('');
456}
457
458function refreshEduList(root) {
459  const list = root.querySelector('#rb-edu-list');
460  if (list) list.innerHTML = educations.map((e, i) => htmlEduRow(i, e)).join('');
461}
462
463function syncExpState(root) {
464  root.querySelectorAll('[data-exp]').forEach((g, i) => {
465    if (!experiences[i]) experiences[i] = {};
466    experiences[i].company = g.querySelector('.rb-exp-company')?.value || '';
467    experiences[i].title   = g.querySelector('.rb-exp-title')?.value   || '';
468    experiences[i].dates   = g.querySelector('.rb-exp-dates')?.value   || '';
469    experiences[i].bullets = g.querySelector('.rb-exp-bullets')?.value || '';
470  });
471}
472
473function syncEduState(root) {
474  root.querySelectorAll('[data-edu]').forEach((g, i) => {
475    if (!educations[i]) educations[i] = {};
476    educations[i].school = g.querySelector('.rb-edu-school')?.value || '';
477    educations[i].degree = g.querySelector('.rb-edu-degree')?.value || '';
478    educations[i].dates  = g.querySelector('.rb-edu-dates')?.value  || '';
479  });
480}
481
482// ── Editor helpers ────────────────────────────────────────────────
483
484async function loadAndEditResume(root, id) {
485  const resume = await getResume(id);
486  if (resume) openEditor(root, resume);
487}
488
489function openEditor(root, resume) {
490  currentResume = resume ? { ...resume } : { id: uid() };
491  experiences   = (resume?.content?.experience || []).map(e => ({ ...e }));
492  educations    = (resume?.content?.education  || []).map(e => ({ ...e }));
493
494  root.querySelector('#rb-editor-title').textContent = resume
495    ? (resume.name || 'Edit Resume') : 'New Resume';
496
497  root.querySelector('#rb-name').value        = resume?.name        || '';
498  root.querySelector('#rb-target-role').value = resume?.target_role || (!resume && _rbProfile?.target_role) || '';
499  root.querySelector('#rb-job-desc').value    = '';
500  root.querySelector('#rb-raw-text').value    = resume?.raw_text    || '';
501
502  const c = resume?.content || {};
503  root.querySelector('#rb-c-name').value     = c.contact?.name     || '';
504  root.querySelector('#rb-c-email').value    = c.contact?.email    || '';
505  root.querySelector('#rb-c-phone').value    = c.contact?.phone    || '';
506  root.querySelector('#rb-c-linkedin').value = c.contact?.linkedin || '';
507  root.querySelector('#rb-c-location').value = c.contact?.location || '';
508  root.querySelector('#rb-summary').value    = c.summary           || '';
509  root.querySelector('#rb-skills').value     = (c.skills || []).join(', ');
510  root.querySelector('#rb-certs').value      = (c.certifications || []).join(', ');
511
512  refreshExpList(root);
513  refreshEduList(root);
514
515  root.querySelector('#rb-error').classList.add('hidden');
516  root.querySelector('#rb-structured').classList.add('hidden');
517  root.querySelector('#rb-toggle-structured')?.classList.remove('open');
518
519  showScreen('b', root);
520}
521
522function collectData(root) {
523  syncExpState(root);
524  syncEduState(root);
525  return {
526    id:          currentResume.id,
527    name:        root.querySelector('#rb-name')?.value?.trim()        || 'Untitled Resume',
528    target_role: root.querySelector('#rb-target-role')?.value?.trim() || '',
529    raw_text:    root.querySelector('#rb-raw-text')?.value?.trim()    || '',
530    content: {
531      contact: {
532        name:     root.querySelector('#rb-c-name')?.value?.trim()     || '',
533        email:    root.querySelector('#rb-c-email')?.value?.trim()    || '',
534        phone:    root.querySelector('#rb-c-phone')?.value?.trim()    || '',
535        linkedin: root.querySelector('#rb-c-linkedin')?.value?.trim() || '',
536        location: root.querySelector('#rb-c-location')?.value?.trim() || '',
537      },
538      summary:        root.querySelector('#rb-summary')?.value?.trim() || '',
539      experience:     experiences,
540      education:      educations,
541      skills:         splitComma(root.querySelector('#rb-skills')?.value),
542      certifications: splitComma(root.querySelector('#rb-certs')?.value),
543    },
544  };
545}
546
547function buildText(root) {
548  const raw = root.querySelector('#rb-raw-text')?.value?.trim();
549  if (raw) return raw;
550
551  const parts = [];
552  const name = root.querySelector('#rb-c-name')?.value?.trim();
553  if (name) parts.push(name);
554
555  const contact = [
556    root.querySelector('#rb-c-email')?.value?.trim(),
557    root.querySelector('#rb-c-phone')?.value?.trim(),
558    root.querySelector('#rb-c-location')?.value?.trim(),
559    root.querySelector('#rb-c-linkedin')?.value?.trim(),
560  ].filter(Boolean);
561  if (contact.length) parts.push(contact.join(' | '));
562
563  const summary = root.querySelector('#rb-summary')?.value?.trim();
564  if (summary) { parts.push('\nSUMMARY'); parts.push(summary); }
565
566  syncExpState(root);
567  if (experiences.length) {
568    parts.push('\nEXPERIENCE');
569    experiences.forEach(e => {
570      const hdr = [e.title, e.company].filter(Boolean).join(' at ') + (e.dates ? ` (${e.dates})` : '');
571      if (hdr.trim()) parts.push(hdr);
572      if (e.bullets) parts.push(e.bullets);
573    });
574  }
575
576  syncEduState(root);
577  if (educations.length) {
578    parts.push('\nEDUCATION');
579    educations.forEach(e => {
580      const line = [e.degree, e.school].filter(Boolean).join(', ') + (e.dates ? ` (${e.dates})` : '');
581      if (line.trim()) parts.push(line);
582    });
583  }
584
585  const skills = root.querySelector('#rb-skills')?.value?.trim();
586  if (skills) { parts.push('\nSKILLS'); parts.push(skills); }
587
588  const certs = root.querySelector('#rb-certs')?.value?.trim();
589  if (certs) { parts.push('\nCERTIFICATIONS'); parts.push(certs); }
590
591  return parts.join('\n');
592}
593
594function splitComma(str) {
595  return (str || '').split(',').map(s => s.trim()).filter(Boolean);
596}
597
598// ── Save & Score ─────────────────────────────────────────────────
599
600async function handleSave(root, andScore) {
601  const errorEl  = root.querySelector('#rb-error');
602  const scoreBtn = root.querySelector('#rb-save-score');
603  const draftBtn = root.querySelector('#rb-save-draft');
604
605  errorEl.classList.add('hidden');
606
607  const resumeText = buildText(root);
608  const targetRole = root.querySelector('#rb-target-role')?.value?.trim();
609  const jobDesc    = root.querySelector('#rb-job-desc')?.value?.trim();
610
611  if (andScore) {
612    if (!resumeText) { showError(errorEl, 'Please add resume content before scoring.'); return; }
613    if (!targetRole) { showError(errorEl, 'Please enter a target role before scoring.'); return; }
614    scoreBtn.disabled = true;
615    scoreBtn.innerHTML = t('rb.analyzing');
616  }
617
618  try {
619    const data = collectData(root);
620    // Persist the full assembled text (so structured-only resumes still carry
621    // content) and remember the JD so the rewrite step has the same context.
622    if (andScore) { data.raw_text = resumeText; _lastJobDesc = jobDesc || ''; }
623    await saveResume(data);
624    resumes = await getResumes();
625    markPhase1Completed();   // honest flag: a resume actually exists now
626
627    if (!andScore) {
628      const orig = draftBtn.textContent;
629      draftBtn.textContent = t('msg.saved');
630      setTimeout(() => { draftBtn.textContent = orig; }, 1500);
631      // A saved draft IS a created resume — Phase 2 must not depend on the
632      // user also scoring it (4 of the first 10 signups saved without ever
633      // scoring and never got the flow). Self-gating, so safe to call here.
634      maybeStartPipelineOnboarding();
635      return;
636    }
637
638    // Scoring is FREE for everyone (Part 9) — no quota check here. The metered
639    // step is "Generate My New Resume" on the score screen.
640    const token = await getAccessToken();
641    const res = await fetch('/api/resume-score', {
642      method:  'POST',
643      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
644      body: JSON.stringify({
645        resumeText,
646        targetRole,
647        ...(jobDesc ? { jobDescription: jobDesc } : {}),
648      }),
649    });
650
651    if (!res.ok) {
652      const body = await res.json().catch(() => ({}));
653      throw new Error(body.error || `Server error ${res.status}`);
654    }
655
656    const results = await res.json();
657
658    await saveResume({ ...data, ai_score: results.overallScore, ai_feedback: results });
659    resumes = await getResumes();
660
661    renderScoreScreen(root, data, results);
662    showScreen('c', root);
663
664    // Phase-2 onboarding: first resume scored → Kemba offers to create the
665    // user's first tracked pipeline item. One-time; self-gating.
666    maybeStartPipelineOnboarding();
667
668  } catch (err) {
669    showError(errorEl, err.message || 'Something went wrong. Please try again.');
670  } finally {
671    if (scoreBtn) {
672      scoreBtn.disabled = false;
673      scoreBtn.innerHTML = t('rb.score_btn');
674    }
675  }
676}
677
678function showError(el, msg) {
679  el.textContent = msg;
680  el.classList.remove('hidden');
681}
682
683// ── Screen C: AI results ─────────────────────────────────────────
684
685function renderScoreScreen(root, resumeData, results) {
686  const score  = results.overallScore;
687  const color  = scoreColor(score);
688  const offset = CIRC - (score / 100) * CIRC;
689  const secs   = results.sections || {};
690
691  root.querySelector('#rb-screen-c').innerHTML = `
692    <div class="view-header">
693      <button class="back-btn" id="rb-back-editor" title="Back to editor">
694        ${svgIcon('arrow-left', 'currentColor', 16)}
695      </button>
696      <h2 class="view-header-title">${escapeHtml(resumeData.name || t('rb.score_results'))}</h2>
697    </div>
698
699    <div class="rb-score-layout">
700
701      <div class="rb-gauge-card">
702        <svg viewBox="0 0 120 120" width="160" height="160" aria-label="ATS score ${score}">
703          <circle cx="60" cy="60" r="54" fill="none" stroke="var(--bg-raised)" stroke-width="10"/>
704          <circle class="rb-gauge-arc" cx="60" cy="60" r="54" fill="none"
705            stroke="${color}" stroke-width="10" stroke-linecap="round"
706            stroke-dasharray="${CIRC.toFixed(2)}"
707            stroke-dashoffset="${CIRC.toFixed(2)}"
708            transform="rotate(-90 60 60)"/>
709          <text x="60" y="62" text-anchor="middle" font-size="24" font-weight="700"
710            fill="${color}" font-family="system-ui,-apple-system,sans-serif">${fmt10(score)}</text>
711          <text x="60" y="80" text-anchor="middle" font-size="11"
712            fill="var(--text-muted)" font-family="system-ui,-apple-system,sans-serif">${t('rb.score_of10')}</text>
713        </svg>
714        <div class="rb-gauge-label" style="color:${color}">${scoreLabel(score)}</div>
715      </div>
716
717      <div class="rb-sections-list">
718        ${Object.entries(secs).map(([key, s]) => htmlSectionBar(key, s)).join('')}
719      </div>
720
721    </div>
722
723    <div class="rb-result-block">
724      <div class="form-section-title">${t('rb.top3')}</div>
725      <div class="rb-improvements">
726        ${(results.topImprovements || []).slice(0, 3).map((txt, i) => `
727          <div class="rb-improve-card">
728            <div class="rb-improve-num">${i + 1}</div>
729            <div class="rb-improve-text">${escapeHtml(txt)}</div>
730          </div>
731        `).join('')}
732      </div>
733    </div>
734
735    <div class="rb-result-block">
736      <div class="form-section-title">${t('rb.ats_keywords')}</div>
737      <div class="rb-kw-grid">
738        <div>
739          <div class="rb-kw-heading" style="color:#22c55e">${t('rb.kw_found')}</div>
740          <div class="rb-chips">
741            ${(results.atsKeywords?.found || []).map(k => `<span class="rb-chip rb-chip-found">${escapeHtml(k)}</span>`).join('')}
742          </div>
743        </div>
744        <div>
745          <div class="rb-kw-heading" style="color:#ef4444">${t('rb.kw_missing')}</div>
746          <div class="rb-chips">
747            ${(results.atsKeywords?.missing || []).map(k => `<span class="rb-chip rb-chip-missing">${escapeHtml(k)}</span>`).join('')}
748          </div>
749        </div>
750        <div>
751          <div class="rb-kw-heading" style="color:var(--accent)">${t('rb.kw_recommended')}</div>
752          <div class="rb-chips">
753            ${(results.atsKeywords?.recommended || []).map(k => `<span class="rb-chip rb-chip-rec">${escapeHtml(k)}</span>`).join('')}
754          </div>
755        </div>
756      </div>
757    </div>
758
759    <div class="rb-result-block">
760      <div class="form-section-title">${t('rb.rewritten_summary')}</div>
761      <div class="rb-rewrite-card">
762        <p>${escapeHtml(results.rewrittenSummary || '')}</p>
763        <button class="btn btn-sm btn-secondary" id="rb-copy-btn" style="margin-top:12px">
764          ${svgIcon('clipboard', 'currentColor', 12)} ${t('rb.copy')}
765        </button>
766      </div>
767    </div>
768
769    <div class="rb-action-bar" style="margin-top:8px;margin-bottom:40px">
770      <button class="btn btn-secondary btn-sm" id="rb-dl-pdf">
771        ${svgIcon('download', 'currentColor', 13)} PDF
772      </button>
773      <button class="btn btn-secondary btn-sm" id="rb-dl-docx">
774        ${svgIcon('download', 'currentColor', 13)} Word
775      </button>
776      <button class="btn btn-primary" id="rb-generate-new">
777        ${t('rb.generate')}
778      </button>
779    </div>
780  `;
781
782  // Trigger gauge animation after two frames so the browser has painted the initial state
783  requestAnimationFrame(() => requestAnimationFrame(() => {
784    const arc = root.querySelector('.rb-gauge-arc');
785    if (arc) arc.style.strokeDashoffset = offset.toFixed(2);
786  }));
787
788  root.querySelector('#rb-back-editor')?.addEventListener('click', () => showScreen('b', root));
789  root.querySelector('#rb-generate-new')?.addEventListener('click', () => generateNewResume(root, resumeData, results));
790
791  root.querySelector('#rb-dl-pdf')?.addEventListener('click',  () => downloadResumePDF(resumeData));
792  root.querySelector('#rb-dl-docx')?.addEventListener('click', () => downloadResumeDocx(resumeData));
793
794  root.querySelector('#rb-copy-btn')?.addEventListener('click', async () => {
795    const btn = root.querySelector('#rb-copy-btn');
796    try {
797      await navigator.clipboard.writeText(results.rewrittenSummary || '');
798      btn.innerHTML = `${svgIcon('check', 'currentColor', 12)} ${t('rb.copied')}`;
799      setTimeout(() => { btn.innerHTML = `${svgIcon('clipboard', 'currentColor', 12)} ${t('rb.copy')}`; }, 2000);
800    } catch {
801      btn.textContent = 'Copy failed';
802    }
803  });
804}
805
806function htmlSectionBar(key, s) {
807  if (!s) return '';
808  const c = scoreColor(s.score || 0);
809  // Section names come from the scoring API — translate known ones, fall back
810  // to a capitalized key for anything new the model invents.
811  const nameKey = 'rb.sec_' + String(key).toLowerCase();
812  const name = hasKey(nameKey) ? t(nameKey) : key.charAt(0).toUpperCase() + key.slice(1);
813  return `
814    <div class="rb-section-bar">
815      <div class="rb-section-bar-head">
816        <span class="rb-section-bar-name">${name}</span>
817        <span class="rb-section-bar-score" style="color:${c}">${fmt10(s.score)}/10</span>
818      </div>
819      <div class="rb-progress-track">
820        <div class="rb-progress-fill" style="width:${s.score}%;background:${c}"></div>
821      </div>
822      <div class="rb-section-feedback">${escapeHtml(s.feedback || '')}</div>
823    </div>
824  `;
825}
826
827// ── Screen D: Generate new resume (loading → reveal) ──────────────
828
829function revealLoadingHTML() {
830  return `
831    <div class="rb-reveal-loading">
832      <div class="rb-reveal-spinner"></div>
833      <div class="rb-reveal-loading-title">${t('rb.finalizing')}</div>
834      <div class="rb-reveal-loading-sub">${t('rb.finalizing_sub')}</div>
835    </div>`;
836}
837
838// "Generate My New Resume" — the metered step (consumes one AI-resume use).
839async function generateNewResume(root, resumeData, results) {
840  // Free 2/mo · Global 5 · Pro 20 · Pro Max+ unlimited. canScoreResume() now
841  // gates the rewrite (scoring itself is free).
842  if (!canScoreResume()) {
843    showUpgradeModal(isPro() ? 'resume_pro_limit' : 'resume_limit');
844    return;
845  }
846
847  ensureRevealStyles();
848  const screenD = root.querySelector('#rb-screen-d');
849  screenD.innerHTML = revealLoadingHTML();
850  showScreen('d', root);
851
852  const missing = [
853    ...(results.atsKeywords?.missing || []),
854    ...(results.atsKeywords?.recommended || []),
855  ];
856
857  try {
858    const token = await getAccessToken();
859    const res = await fetch('/api/ai?action=resume-rewrite', {
860      method: 'POST',
861      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
862      body: JSON.stringify({
863        resumeText:  resumeData.raw_text || '',
864        targetRole:  resumeData.target_role || '',
865        ...(_lastJobDesc ? { jobDescription: _lastJobDesc } : {}),
866        missingKeywords: missing,
867      }),
868    });
869
870    if (!res.ok) {
871      const body = await res.json().catch(() => ({}));
872      if (res.status === 402 || body.error === 'upgrade_required') {
873        showUpgradeModal(body.reason || (isPro() ? 'resume_pro_limit' : 'resume_limit'));
874        showScreen('c', root);
875        return;
876      }
877      throw new Error(body.error || `Server error ${res.status}`);
878    }
879
880    const rewrite = await res.json();
881    markResumeUsed();
882
883    _revealBase     = resumeData;
884    _revealRewrite  = rewrite;
885    _revealOldScore = results.overallScore;
886    renderRevealScreen(root);
887  } catch (err) {
888    showScreen('c', root);
889    showToast(err.message || 'Could not generate your new resume. Please try again.', 'error');
890  }
891}
892
893function renderRevealScreen(root) {
894  ensureRevealStyles();
895  const base    = _revealBase || {};
896  const rw      = _revealRewrite || {};
897  const oldS    = _revealOldScore ?? 0;
898  const newS    = rw.newScore ?? oldS;
899  const color   = scoreColor(newS);
900  const offset  = CIRC - (newS / 100) * CIRC;
901  const changes = Array.isArray(rw.changes) ? rw.changes : [];
902
903  const screenD = root.querySelector('#rb-screen-d');
904  screenD.innerHTML = `
905    <div class="view-header">
906      <button class="back-btn" id="rb-reveal-back" title="Back to score">
907        ${svgIcon('arrow-left', 'currentColor', 16)}
908      </button>
909      <h2 class="view-header-title">${t('rb.your_new_resume')}</h2>
910    </div>
911
912    <div class="rb-reveal-scorebanner">
913      <svg viewBox="0 0 120 120" width="92" height="92" aria-label="New score ${fmt10(newS)}">
914        <circle cx="60" cy="60" r="54" fill="none" stroke="var(--bg-raised)" stroke-width="10"/>
915        <circle class="rb-reveal-arc" cx="60" cy="60" r="54" fill="none"
916          stroke="${color}" stroke-width="10" stroke-linecap="round"
917          stroke-dasharray="${CIRC.toFixed(2)}" stroke-dashoffset="${CIRC.toFixed(2)}"
918          transform="rotate(-90 60 60)"/>
919        <text x="60" y="64" text-anchor="middle" font-size="26" font-weight="700"
920          fill="${color}" font-family="system-ui,-apple-system,sans-serif">${fmt10(newS)}</text>
921        <text x="60" y="80" text-anchor="middle" font-size="11"
922          fill="var(--text-muted)" font-family="system-ui,-apple-system,sans-serif">${t('rb.score_of10')}</text>
923      </svg>
924      <div class="rb-reveal-scoretext">
925        <div class="rb-reveal-jump">${t('rb.score_jumped', { from: fmt10(oldS), to: `<strong style="color:${color}">${fmt10(newS)}</strong>` })}</div>
926        <div class="rb-reveal-jump-sub">${t('rb.reveal_sub')}</div>
927      </div>
928    </div>
929
930    <div class="rb-reveal-layout">
931      <div class="rb-reveal-main">
932        <div class="rb-reveal-main-head">${t('rb.rewritten_resume')}</div>
933        <pre class="rb-reveal-resume" id="rb-reveal-text">${escapeHtml(rw.rewrittenResume || '')}</pre>
934      </div>
935
936      <aside class="rb-reveal-side">
937        <div class="rb-kemba-head">
938          <div class="rb-kemba-avatar">K<span class="rb-kemba-dot"></span></div>
939          <div>
940            <div class="rb-kemba-name">Kemba</div>
941            <div class="rb-kemba-role">${t('rb.coach_role')}</div>
942          </div>
943        </div>
944        <div class="rb-kemba-changes-title">${t('rb.what_changed')}</div>
945        <ul class="rb-kemba-changes">
946          ${changes.length
947            ? changes.map(c => `<li>${escapeHtml(c)}</li>`).join('')
948            : `<li>${t('rb.default_change')}</li>`}
949        </ul>
950
951        <div class="rb-kemba-chips-title">${t('rb.quick_tweaks')}</div>
952        <div class="rb-kemba-chips">
953          <button class="rb-kemba-chip" data-tweak="Use stronger, more varied action verbs throughout the experience bullets.">${t('rb.tweak_verbs')}</button>
954          <button class="rb-kemba-chip" data-tweak="Shorten the professional summary to 2–3 tight sentences.">${t('rb.tweak_shorten')}</button>
955          <button class="rb-kemba-chip" data-tweak="Remove skills and lines that aren't relevant to the target role.">${t('rb.tweak_remove')}</button>
956        </div>
957
958        <div class="rb-kemba-ask">
959          <textarea id="rb-kemba-input" class="rb-kemba-textarea" rows="3"
960            placeholder="${t('rb.tweak_ph')}"></textarea>
961          <button class="btn btn-primary btn-sm" id="rb-kemba-edit" style="width:100%">${t('rb.edit_kemba')}</button>
962        </div>
963      </aside>
964    </div>
965
966    <div class="rb-action-bar rb-reveal-actions">
967      <button class="btn btn-secondary btn-sm" id="rb-reveal-pdf">${svgIcon('download', 'currentColor', 13)} ${t('rb.download_pdf')}</button>
968      <button class="btn btn-secondary btn-sm" id="rb-reveal-docx">${svgIcon('download', 'currentColor', 13)}
968 ${t('rb.download_word')}</button>
969      <button class="btn btn-primary" id="rb-reveal-save">${svgIcon('check', '#000', 14)} ${t('rb.save_trackply')}</button>
970    </div>
971  `;
972
973  requestAnimationFrame(() => requestAnimationFrame(() => {
974    const arc = screenD.querySelector('.rb-reveal-arc');
975    if (arc) arc.style.strokeDashoffset = offset.toFixed(2);
976  }));
977
978  showScreen('d', root);
979
980  screenD.querySelector('#rb-reveal-back')?.addEventListener('click', () => showScreen('c', root));
981  screenD.querySelectorAll('.rb-kemba-chip').forEach(chip =>
982    chip.addEventListener('click', () => refineResume(root, chip.dataset.tweak)));
983  screenD.querySelector('#rb-kemba-edit')?.addEventListener('click', () => {
984    const v = screenD.querySelector('#rb-kemba-input')?.value?.trim();
985    if (!v) { showToast(t('rb.tell_kemba_first'), 'info'); return; }
986    refineResume(root, v);
987  });
988
989  const dlData = () => ({ name: (base.name || 'Resume') + ' ' + t('rb.tailored_suffix'), raw_text: rw.rewrittenResume || '' });
990  screenD.querySelector('#rb-reveal-pdf')?.addEventListener('click',  () => downloadResumePDF(dlData()));
991  screenD.querySelector('#rb-reveal-docx')?.addEventListener('click', () => downloadResumeDocx(dlData()));
992  screenD.querySelector('#rb-reveal-save')?.addEventListener('click', () => saveRewrittenResume(root));
993
994  // Phase-2 onboarding: "Your New Resume" is the other completion point.
995  maybeStartPipelineOnboarding();
996}
997
998// Targeted tweak via Kemba (unmetered refine) — chips + the free-form box.
999async function refineResume(root, instruction) {
1000  const screenD = root.querySelector('#rb-screen-d');
1001  const editBtn = screenD.querySelector('#rb-kemba-edit');
1002  const chips   = screenD.querySelectorAll('.rb-kemba-chip');
1003  if (editBtn) { editBtn.disabled = true; editBtn.textContent = t('rb.editing'); }
1004  chips.forEach(c => c.disabled = true);
1005
1006  try {
1007    const token = await getAccessToken();
1008    const res = await fetch('/api/ai?action=resume-refine', {
1009      method: 'POST',
1010      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1011      body: JSON.stringify({
1012        resumeText:  _revealRewrite?.rewrittenResume || '',
1013        targetRole:  _revealBase?.target_role || '',
1014        ...(_lastJobDesc ? { jobDescription: _lastJobDesc } : {}),
1015        instruction,
1016      }),
1017    });
1018    if (!res.ok) {
1019      const body = await res.json().catch(() => ({}));
1020      throw new Error(body.error || `Server error ${res.status}`);
1021    }
1022    const rewrite = await res.json();
1023    // The score is relative to the previous rewrite; keep the "from" anchor as the
1024    // original score so the jump still reads against where they started.
1025    _revealRewrite = rewrite;
1026    renderRevealScreen(root);
1027    showToast(t('rb.updated_toast'), 'success');
1028  } catch (err) {
1029    if (editBtn) { editBtn.disabled = false; editBtn.textContent = t('rb.edit_kemba'); }
1030    chips.forEach(c => c.disabled = false);
1031    showToast(err.message || 'Could not apply that tweak. Please try again.', 'error');
1032  }
1033}
1034
1035// Save the rewritten resume as a NEW resume record in Trackply.
1036async function saveRewrittenResume(root) {
1037  const base = _revealBase || {};
1038  const rw   = _revealRewrite || {};
1039  const btn  = root.querySelector('#rb-reveal-save');
1040  if (btn) { btn.disabled = true; btn.textContent = t('msg.saving'); }
1041  try {
1042    const newId = uid();
1043    await saveResume({
1044      id:          newId,
1045      name:        (base.name || 'Resume') + ' ' + t('rb.tailored_suffix'),
1046      target_role: base.target_role || '',
1047      raw_text:    rw.rewrittenResume || '',
1048      ai_score:    rw.newScore ?? null,
1049      content:     {},
1050    });
1051    resumes = await getResumes();
1052    markPhase1Completed();
1053    if (btn) { btn.disabled = false; btn.innerHTML = `${svgIcon('check', '#000', 14)} ${t('rb.saved_check')}`; }
1054    showToast('Saved to your resumes.', 'success');
1055  } catch (err) {
1056    if (btn) { btn.disabled = false; btn.innerHTML = `${svgIcon('check', '#000', 14)} Save to Trackply`; }
1057    showToast(err.message || 'Could not save. Please try again.', 'error');
1058  }
1059}
1060
1061function ensureRevealStyles() {
1062  if (document.getElementById('rb-reveal-styles')) return;
1063  const s = document.createElement('style');
1064  s.id = 'rb-reveal-styles';
1065  s.textContent = `
1066    .rb-reveal-loading{display:flex;flex-direction:column;align-items:center;justify-content:center;
1067      gap:14px;text-align:center;min-height:50vh;padding:30px;}
1068    .rb-reveal-spinner{width:46px;height:46px;border-radius:50%;border:3px solid var(--border,rgba(255,255,255,.12));
1069      border-top-color:var(--accent,#00d4ff);animation:rbSpin .8s linear infinite;}
1070    @keyframes rbSpin{to{transform:rotate(360deg)}}
1071    .rb-reveal-loading-title{font-size:18px;font-weight:800;color:var(--text-primary,#eef1f8);}
1072    .rb-reveal-loading-sub{font-size:13px;color:var(--text-muted,#8a93a8);max-width:380px;line-height:1.5;}
1073
1074    .rb-reveal-scorebanner{display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:6px 0 18px;
1075      padding:16px 18px;border-radius:16px;border:1px solid rgba(34,197,94,.3);
1076      background:linear-gradient(135deg,rgba(34,197,94,.10),rgba(0,212,255,.06));}
1077    .rb-reveal-jump{font-size:18px;font-weight:800;color:var(--text-primary,#eef1f8);}
1078    .rb-reveal-jump strong{font-weight:800;}
1079    .rb-reveal-jump-sub{font-size:13px;color:var(--text-muted,#8a93a8);margin-top:3px;}
1080
1081    .rb-reveal-layout{display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start;}
1082    @media(max-width:860px){.rb-reveal-layout{grid-template-columns:1fr;}}
1083    .rb-reveal-main{border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;overflow:hidden;background:var(--surface-2,rgba(255,255,255,.02));}
1084    .rb-reveal-main-head{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;
1085      color:var(--text-muted,#8a93a8);padding:12px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,.1));}
1086    .rb-reveal-resume{margin:0;padding:18px;white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,'SF Mono',Menlo,monospace;
1087      font-size:12.5px;line-height:1.6;color:var(--text-primary,#eef1f8);max-height:70vh;overflow-y:auto;}
1088
1089    .rb-reveal-side{border:1px solid rgba(124,92,255,.3);border-radius:14px;padding:16px;
1090      background:linear-gradient(180deg,rgba(124,92,255,.08),rgba(124,92,255,.02));position:sticky;top:12px;}
1091    .rb-kemba-head{display:flex;align-items:center;gap:10px;margin-bottom:14px;}
1092    .rb-kemba-avatar{position:relative;width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;
1093      font-weight:800;color:#06080f;background:linear-gradient(135deg,#7c5cff,#28c8ff);}
1094    .rb-kemba-dot{position:absolute;right:1px;bottom:1px;width:10px;height:10px;border-radius:50%;background:#22e07a;border:2px solid #11121d;}
1095    .rb-kemba-name{font-size:14px;font-weight:800;color:var(--text-primary,#eef1f8);}
1096    .rb-kemba-role{font-size:11px;color:var(--text-muted,#8a93a8);}
1097    .rb-kemba-changes-title,.rb-kemba-chips-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;
1098      color:var(--text-muted,#8a93a8);margin:6px 0 8px;}
1099    .rb-kemba-changes{margin:0 0 14px;padding-left:18px;font-size:12.5px;line-height:1.6;color:var(--text-secondary,#aeb6cb);}
1100    .rb-kemba-changes li{margin-bottom:5px;}
1101    .rb-kemba-chips{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:14px;}
1102    .rb-kemba-chip{font-size:11.5px;font-weight:600;padding:6px 11px;border-radius:999px;cursor:pointer;
1103      color:#cfc6ff;background:rgba(124,92,255,.16);border:1px solid rgba(124,92,255,.4);}
1104    [data-theme="light"] .rb-kemba-chip{color:#5b3bd6;background:rgba(124,92,255,.10);}
1105    .rb-kemba-chip:hover{filter:brightness(1.12);}
1106    .rb-kemba-chip:disabled{opacity:.55;cursor:default;}
1107    .rb-kemba-textarea{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;font-size:13px;font-family:inherit;
1108      color:var(--text-primary,#eef1f8);background:var(--bg-surface);border:1px solid var(--border-mid);margin-bottom:8px;resize:vertical;}
1109    .rb-kemba-textarea:focus{outline:none;border-color:rgba(124,92,255,.7);}
1110    .rb-reveal-actions{margin-top:18px;margin-bottom:40px;}
1111  `;
1112  document.head.appendChild(s);
1113}
1114
1115// ── Shared helpers ────────────────────────────────────────────────
1116
1117function showScreen(s, root) {
1118  root.querySelector('#rb-screen-a').classList.toggle('hidden', s !== 'a');
1119  root.querySelector('#rb-screen-b').classList.toggle('hidden', s !== 'b');
1120  root.querySelector('#rb-screen-c').classList.toggle('hidden', s !== 'c');
1121  root.querySelector('#rb-screen-d')?.classList.toggle('hidden', s !== 'd');
1122}
1123
1124async function confirmDelete(root, id) {
1125  if (!confirm('Delete this resume? This cannot be undone.')) return;
1126  try {
1127    await deleteResume(id);
1128    resumes = await getResumes();
1129    root.querySelector('#rb-list').innerHTML = htmlResumeList();
1130  } catch (err) {
1131    alert('Failed to delete: ' + err.message);
1132  }
1133}
1134
1135// ── PDF text extraction (PDF.js loaded on demand) ─────────────────
1136
1137export async function extractPDFText(file) {
1138  if (!window.pdfjsLib) {
1139    await new Promise((resolve, reject) => {
1140      const s = document.createElement('script');
1141      s.src     = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
1142      s.onload  = resolve;
1143      s.onerror = () => reject(new Error('Could not load PDF.js'));
1144      document.head.appendChild(s);
1145    });
1146    window.pdfjsLib.GlobalWorkerOptions.workerSrc =
1147      'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
1148  }
1149
1150  const buf = await file.arrayBuffer();
1151  const pdf = await window.pdfjsLib.getDocument({ data: buf }).promise;
1152  const parts = [];
1153  for (let i = 1; i <= pdf.numPages; i++) {
1154    const page    = await pdf.getPage(i);
1155    const content = await page.getTextContent();
1156    parts.push(content.items.map(item => item.str).join(' '));
1157  }
1158  return parts.join('\n\n').trim();
1159}
1160
1161// ── Download as PDF (print dialog) ───────────────────────────────
1162
1163function downloadResumePDF(resumeData) {
1164  const text = resumeData.raw_text || '';
1165  const name = escapeHtml(resumeData.name || 'Resume');
1166  const body = text.split(/\n{2,}/)
1167    .map(p =>
1167 p.trim()).filter(Boolean)
1168    .map(p => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`)
1169    .join('\n');
1170
1171  const win = window.open('', '_blank');
1172  if (!win) return;
1173  win.document.write(`<!DOCTYPE html><html lang="en"><head>
1174<meta charset="UTF-8"><title>${name}</title>
1175<style>body{font-family:Georgia,'Times New Roman',serif;max-width:680px;margin:72px auto;line-height:1.75;color:#111;font-size:14px}
1176p{margin:0 0 12px}h1{font-family:Arial,sans-serif;font-size:14px;color:#555;margin-bottom:28px;font-weight:400}
1177@media print{body{margin:0}}</style></head><body>
1178<h1>${name}</h1>${body}</body></html>`);
1179  win.document.close();
1180  win.focus();
1181  setTimeout(() => win.print(), 250);
1182}
1183
1184// ── Download as Word (.docx via docx.js CDN) ─────────────────────
1185
1186async function downloadResumeDocx(resumeData) {
1187  if (!window.docx) {
1188    await new Promise((resolve, reject) => {
1189      const s = document.createElement('script');
1190      s.src     = 'https://cdnjs.cloudflare.com/ajax/libs/docx/7.8.2/docx.js';
1191      s.onload  = resolve;
1192      s.onerror = () => reject(new Error('Could not load docx.js'));
1193      document.head.appendChild(s);
1194    });
1195  }
1196
1197  const { Document, Paragraph, TextRun, HeadingLevel, Packer } = window.docx;
1198  const text  = resumeData.raw_text || '';
1199  const lines = text.split('\n').filter(l => l.trim());
1200
1201  const children = lines.map(line => {
1202    const t = line.trim();
1203    // Treat short ALL-CAPS lines as headings
1204    const isHeading = t.length < 50 && t === t.toUpperCase() && /[A-Z]/.test(t);
1205    return isHeading
1206      ? new Paragraph({ text: t, heading: HeadingLevel.HEADING_2, spacing: { before: 240, after: 80 } })
1207      : new Paragraph({ children: [new TextRun({ text: t })], spacing: { after: 100 } });
1208  });
1209
1210  const doc  = new Document({
1211    sections: [{ properties: { page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } }, children }],
1212  });
1213  const blob = await Packer.toBlob(doc);
1214  const url  = URL.createObjectURL(blob);
1215  const a    = document.createElement('a');
1216  a.href     = url;
1217  a.download = `${(resumeData.name || 'Resume').replace(/\s+/g, '_')}.docx`;
1218  a.click();
1219  URL.revokeObjectURL(url);
1220}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.