From 14eb52ab9049f82ec5e49544b66e82d640ddfe01 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Tue, 9 Jun 2026 13:09:50 -0500
Subject: [PATCH] Improve mobile sidebar and diff section serialization
---
frontend/css/mobile.css | 14 +++--
frontend/css/sidebar.css | 6 ++-
frontend/index.html | 8 +--
frontend/js/diff.js | 111 ++++++++++++++++++++++++++++++++++-----
4 files changed, 117 insertions(+), 22 deletions(-)
diff --git a/frontend/css/mobile.css b/frontend/css/mobile.css
index c28a9f5..828f819 100644
--- a/frontend/css/mobile.css
+++ b/frontend/css/mobile.css
@@ -1,22 +1,30 @@
/* Responsive overrides — small viewports up to 700px, narrow phones up to 400px. */
@media(max-width:700px){
- /* Sidebar becomes a slide-in drawer */
+ /* Sidebar becomes a slide-in drawer. Use a comfortable fixed width rather than
+ --sw: on a phone-width viewport the desktop resize logic clamps --sw down to
+ its 200px minimum, which collapses the session-name column to a few pixels. */
#sb{
position:fixed;top:0;left:0;
height:100vh;height:100dvh;
z-index:100;
- width:var(--sw)!important;min-width:var(--sw)!important;
+ width:min(86vw,330px)!important;min-width:0!important;
transform:translateX(-100%);
transition:transform .22s ease;
overflow:hidden;
}
#sb.mobile-open{transform:translateX(0)!important}
- #sb.collapsed{transform:translateX(-100%)!important;width:var(--sw)!important;min-width:var(--sw)!important}
+ #sb.collapsed{transform:translateX(-100%)!important;width:min(86vw,330px)!important;min-width:0!important}
.sb-expand{display:none!important}
/* No edge-resizing in the mobile drawer. */
.sb-resizer{display:none!important}
.mob-menu-btn{display:flex}
+ /* Touch has no hover, so the per-row actions (favorite / tracker / rename /
+ delete) would otherwise stay invisible yet still reserve width, squeezing
+ the session title. Reveal them and let the title truncate cleanly. */
+ .si-act{opacity:1}
+ .si-meta{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
/* Main fills the full screen — dvh keeps it within the actual visible area on iOS */
#main{
width:100vw;min-width:0;
diff --git a/frontend/css/sidebar.css b/frontend/css/sidebar.css
index 320250b..e8fb97e 100644
--- a/frontend/css/sidebar.css
+++ b/frontend/css/sidebar.css
@@ -10,8 +10,10 @@
.sb-resizer:hover::after,body.sb-resizing .sb-resizer::after{background:var(--ab)}
#sb.collapsed .sb-resizer{display:none}
body.sb-resizing{cursor:col-resize;user-select:none}
-.sb-expand{position:absolute;left:8px;top:62px;z-index:30;width:34px;height:34px;background:var(--bg3);border:1px solid var(--border2);border-radius:9px;color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s}
-.sb-expand:hover{background:var(--bg4);color:var(--text)}
+/* Lives in the toolbar; hidden until the sidebar is collapsed (toggleSidebar
+ flips display to flex). Sits where the mobile menu button does. */
+.sb-expand{display:none;width:34px;height:34px;background:none;border:1px solid var(--border);border-radius:8px;color:var(--text2);cursor:pointer;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s}
+.sb-expand:hover{background:var(--bg3);color:var(--text)}
.sb-collapse{margin-left:auto;background:none;border:none;color:var(--text3);cursor:pointer;padding:4px 6px;border-radius:6px;display:flex;align-items:center;justify-content:center}
.sb-collapse:hover{color:var(--text);background:var(--bg3)}
.sb-head{padding:18px 14px 12px;border-bottom:1px solid var(--border)}
diff --git a/frontend/index.html b/frontend/index.html
index b7ec74a..f7837fb 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -121,9 +121,6 @@
-
Favorites
@@ -152,6 +149,11 @@
+
+
StackResume
Generating…
openai / gpt-4o
diff --git a/frontend/js/diff.js b/frontend/js/diff.js
index 2eac5a1..11c75b7 100644
--- a/frontend/js/diff.js
+++ b/frontend/js/diff.js
@@ -113,29 +113,112 @@ function _diffWords(a,b){
return parts.map(p=>p.t==='='?esc(p.v):p.t==='+'?`${esc(p.v)}`:`${esc(p.v)}`).join('');
}
+// ── Section serializers ───────────────────────────────────────────────────────
+// Turn each resume section into diff-friendly plain text: one logical entry per
+// block, with a blank line between blocks so the line-diff aligns whole entries.
+// A section/entry present only in the new version reads as all-added (green);
+// one dropped from the new version reads as all-removed (red). Field ordering
+// mirrors the preview so the diff reads naturally. ss()/toArr() come from
+// preview.js and coerce the LLM's loose field shapes.
+const _j=(arr,sep)=>arr.filter(x=>x!=null&&x!=='').join(sep);
+
+function _seEdu(list){return toArr(list).map(e=>{
+ const deg=ss(e.degree)+(ss(e.field_of_study)?` in ${ss(e.field_of_study)}`:'');
+ const L=[_j([deg,ss(e.institution),ss(e.location),_j([ss(e.start_date),ss(e.end_date)],' – ')],' · ')];
+ if(ss(e.gpa))L.push('GPA: '+ss(e.gpa));
+ if(ss(e.honors))L.push(ss(e.honors));
+ const cw=toArr(e.relevant_coursework).map(ss).filter(Boolean);if(cw.length)L.push('Coursework: '+cw.join(', '));
+ const ac=toArr(e.activities).map(ss).filter(Boolean);if(ac.length)L.push('Activities: '+ac.join(', '));
+ return L.join('\n');
+}).join('\n\n');}
+
+function _seProjects(list){return toArr(list).map(p=>{
+ const L=[_j([ss(p.name),ss(p.role),ss(p.type),_j([ss(p.start_date),ss(p.end_date)],' – ')],' · ')];
+ if(ss(p.description))L.push(ss(p.description));
+ const tech=toArr(p.technologies).map(ss).filter(Boolean);if(tech.length)L.push('Tech: '+tech.join(', '));
+ toArr(p.highlights).map(ss).filter(Boolean).forEach(h=>L.push('• '+h));
+ const url=ss(p.url)||ss(p.github);if(url)L.push(url);
+ return L.join('\n');
+}).join('\n\n');}
+
+function _seOSS(list){return toArr(list).map(o=>{
+ const L=[_j([ss(o.project),ss(o.role),ss(o.language),ss(o.stars)?'★ '+ss(o.stars):''],' · ')];
+ if(ss(o.description))L.push(ss(o.description));
+ toArr(o.contributions).map(ss).filter(Boolean).forEach(c=>L.push('• '+c));
+ if(ss(o.url))L.push(ss(o.url));
+ return L.join('\n');
+}).join('\n\n');}
+
+function _seCerts(list){return toArr(list).map(c=>{
+ const d=[];if(ss(c.date))d.push('issued '+ss(c.date));if(ss(c.expiry))d.push('expires '+ss(c.expiry));
+ return _j([ss(c.name),ss(c.issuer),d.join(', '),ss(c.credential_id)?'ID '+ss(c.credential_id):'',ss(c.url)],' · ');
+}).join('\n');}
+
+function _sePubs(list){return toArr(list).map(p=>
+ _j([ss(p.title),ss(p.type),ss(p.venue),ss(p.date),toArr(p.authors).map(ss).filter(Boolean).join(', '),ss(p.url)],' · ')
+).join('\n');}
+
+function _sePatents(list){return toArr(list).map(p=>{
+ const L=[_j([ss(p.title),ss(p.patent_number),ss(p.date)],' · ')];
+ if(ss(p.description))L.push(ss(p.description));
+ return L.join('\n');
+}).join('\n\n');}
+
+function _seVol(list){return toArr(list).map(v=>{
+ const L=[_j([ss(v.role),ss(v.organization),_j([ss(v.start_date),ss(v.end_date)],' – ')],' · ')];
+ if(ss(v.description))L.push(ss(v.description));
+ return L.join('\n');
+}).join('\n\n');}
+
+function _seLangs(list){return toArr(list).map(l=>
+ typeof l==='string'?l:_j([ss(l.language),ss(l.proficiency)?`(${ss(l.proficiency)})`:''],' ')
+).join('\n');}
+
+function _seContact(r){const pi=r.personal_info||{};
+ return _j([ss(pi.full_name),ss(pi.professional_title),ss(pi.email),ss(pi.phone),ss(pi.location),ss(pi.linkedin),ss(pi.github),ss(pi.website)||ss(pi.portfolio)],'\n');
+}
+
function _buildDiffHtml(a,b){
+ a=a||{};b=b||{};
const secs=[];
- const check=(title,va,vb)=>{if(va!==vb)secs.push({title,html:_diffText(va||'',vb||'')});};
+ const check=(title,va,vb)=>{if((va||'')!==(vb||''))secs.push({title,html:_diffText(va||'',vb||'')});};
+ check('Contact',_seContact(a),_seContact(b));
check('Professional Summary',a.professional_summary,b.professional_summary);
- const ccA=(a.core_competencies||[]).join(', ');
- const ccB=(b.core_competencies||[]).join(', ');
+ const ccA=toArr(a.core_competencies).map(ss).filter(Boolean).join(', ');
+ const ccB=toArr(b.core_competencies).map(ss).filter(Boolean).join(', ');
check('Core Competencies',ccA,ccB);
- const expB=b.experience||[];
- const expA=a.experience||[];
- expB.forEach((job,idx)=>{
- const pj=expA[idx];
- const txtA=pj?[...(pj.responsibilities||[]),...(pj.achievements||[])].join('\n'):'';
- const txtB=[...(job.responsibilities||[]),...(job.achievements||[])].join('\n');
- if(txtA!==txtB)secs.push({title:`Experience — ${job.title} @ ${job.company}`,html:_diffText(txtA,txtB)});
- });
-
- const skA=Object.values(a.technical_skills||{}).flat().join(', ');
- const skB=Object.values(b.technical_skills||{}).flat().join(', ');
+ // Experience — per job, so a small wording tweak in one bullet doesn't light up
+ // the whole section. Walking the longer of the two lists also surfaces jobs
+ // added to (or removed from) the new version, not just edited ones.
+ const expA=a.experience||[],expB=b.experience||[];
+ for(let i=0;imetaA[k]!==metaB[k])){