Skip to content

Commit fde0614

Browse files
committed
feat: upgrade AI interview + add European languages + leadership section
- Fix AI route: /api/interview -> /ai/interview-question (was 404) - Upgrade default model: 8B -> 70B (llama-3.3-70b-instruct-fp8-fast) - Enable BYOK for interviews: GPT-4o, Claude, Gemini, DeepSeek, Kimi - Add smart tenant resolution from interview token - Add 13 European languages: FR, DE, ES, PT, IT, NL, PL, SV, DA, FI, NO, RO, EL - Add Leadership section with Faisal K, Managing Director, MBA HRM
1 parent b926721 commit fde0614

3 files changed

Lines changed: 225 additions & 159 deletions

File tree

backend/simpatico-ats.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,8 @@ async function callExternalLLM(cfg, messages, maxTokens, stream = false) {
626626
* Drop-in replacement for env.AI.run("@cf/meta/llama-3.1-8b-instruct", opts).
627627
* Returns { response: string, usage?: object }
628628
*/
629-
const CF_DEFAULT_MODEL = "@cf/meta/llama-3.1-8b-instruct";
629+
const CF_DEFAULT_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
630+
const CF_LIGHT_MODEL = "@cf/meta/llama-3.1-8b-instruct";
630631

631632
async function runLLM(env, tenantId, messages, maxTokens = 1024) {
632633
const aiConfig = await getCompanyAIConfig(env, tenantId);
@@ -3534,19 +3535,33 @@ async function handleMarkNotificationRead(request, env, ctx, [id]) {
35343535
// ── AI Intelligence ───────────────────────────────────────────────────────────
35353536

35363537
async function handleInterviewQuestion(request, env, ctx) {
3537-
// requireAuth(ctx);
3538-
const { messages, token } = await safeJson(request);
3538+
// requireAuth(ctx); -- candidates are unauthenticated
3539+
const { messages, token, max_tokens } = await safeJson(request);
35393540
if (!Array.isArray(messages))
35403541
throw new ValidationError("messages array required");
35413542

3542-
// We could fetch the JD again here using the token,
3543-
// but for efficiency we trust the system prompt built by the frontend
3544-
// which already has the JD. We just need to make sure the AI prioritizes it.
3543+
// Resolve tenant: prefer X-Tenant-ID header, fallback to interview token lookup
3544+
let tenantId = ctx.tenantId;
3545+
if ((!tenantId || tenantId === "default") && token && env.SUPABASE_URL) {
3546+
try {
3547+
const ivRes = await sbFetch(env, "GET",
3548+
`/rest/v1/interviews?token=eq.${encodeURIComponent(token)}&select=company_id&limit=1`,
3549+
null, false, "default");
3550+
if (ivRes.ok) {
3551+
const rows = await ivRes.json();
3552+
if (rows?.[0]?.company_id) tenantId = rows[0].company_id;
3553+
}
3554+
} catch (e) { console.warn("[interview] tenant lookup failed:", e.message); }
3555+
}
35453556

3546-
const result = await runLLM(env, ctx.tenantId, messages, 600);
3557+
// Use tenant's custom AI if configured (BYOK), otherwise use platform default (70B)
3558+
// This enables enterprise customers to use GPT-4o, Claude, Gemini etc. for interviews
3559+
const maxTok = Math.min(Math.max(parseInt(max_tokens) || 600, 100), 2048);
3560+
const result = await runLLM(env, tenantId, messages, maxTok);
35473561

35483562
await audit(env, ctx, "interview.ai_question", "interviews", null, {
35493563
token_hint: token?.slice(0, 8),
3564+
tenant_resolved: tenantId,
35503565
});
35513566
return apiResponse({ response: result.response });
35523567
}

evalis-interview.html

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width,initial-scale=1.0">
6-
<title>AI Interview SimpaticoHR</title>
6+
<title>AI Interview SimpaticoHR</title>
77
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
88
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
99
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
@@ -237,11 +237,11 @@ <h1>Simpatico<span>HR</span></h1>
237237
<p class="setup-sub">Enterprise AI Proctored Interview</p>
238238

239239
<div class="job-card" id="jobCard">
240-
<div class="jc-title" id="jcTitle">Loading position</div>
240+
<div class="jc-title" id="jcTitle">Loading position</div>
241241
<div class="jc-meta">
242-
<span id="jcDept"><i class="fas fa-building"></i> </span>
243-
<span id="jcExp"><i class="fas fa-briefcase"></i> </span>
244-
<span id="jcLoc"><i class="fas fa-map-marker-alt"></i> </span>
242+
<span id="jcDept"><i class="fas fa-building"></i> </span>
243+
<span id="jcExp"><i class="fas fa-briefcase"></i> </span>
244+
<span id="jcLoc"><i class="fas fa-map-marker-alt"></i> </span>
245245
</div>
246246
<div class="jc-skills" id="jcSkills"></div>
247247
</div>
@@ -256,7 +256,7 @@ <h1>Simpatico<span>HR</span></h1>
256256
<input type="file" id="resumeInput" accept=".pdf,.txt" style="display:none" onchange="handleResumeUpload(event)">
257257
</div>
258258
<div style="font-size:11px;color:var(--muted);margin:10px 0;font-weight:700">OR MANUALLY ENTER SKILLS / PROJECTS</div>
259-
<textarea id="manualBackground" class="manual-bg-input" placeholder="E.g., 5 years in React, built scalable microservices, strong in AWS, led team of 6"></textarea>
259+
<textarea id="manualBackground" class="manual-bg-input" placeholder="E.g., 5 years in React, built scalable microservices, strong in AWS, led team of 6"></textarea>
260260
</div>
261261

262262
<div class="lang-label"><i class="fas fa-globe"></i> Select Interview Language</div>
@@ -266,6 +266,19 @@ <h1>Simpatico<span>HR</span></h1>
266266
<button class="lang-btn" data-lang="ml" onclick="pickLang('ml',this)">???? ??????</button>
267267
<button class="lang-btn" data-lang="ta" onclick="pickLang('ta',this)">???? ?????</button>
268268
<button class="lang-btn" data-lang="ar" onclick="pickLang('ar',this)">???? ?????</button>
269+
<button class="lang-btn" data-lang="fr" onclick="pickLang('fr',this)">&#127467;&#127479; Fran&ccedil;ais</button>
270+
<button class="lang-btn" data-lang="de" onclick="pickLang('de',this)">&#127465;&#127466; Deutsch</button>
271+
<button class="lang-btn" data-lang="es" onclick="pickLang('es',this)">&#127466;&#127480; Espa&ntilde;ol</button>
272+
<button class="lang-btn" data-lang="pt" onclick="pickLang('pt',this)">&#127477;&#127481; Portugu&ecirc;s</button>
273+
<button class="lang-btn" data-lang="it" onclick="pickLang('it',this)">&#127470;&#127481; Italiano</button>
274+
<button class="lang-btn" data-lang="nl" onclick="pickLang('nl',this)">&#127475;&#127473; Nederlands</button>
275+
<button class="lang-btn" data-lang="pl" onclick="pickLang('pl',this)">&#127477;&#127473; Polski</button>
276+
<button class="lang-btn" data-lang="sv" onclick="pickLang('sv',this)">&#127480;&#127466; Svenska</button>
277+
<button class="lang-btn" data-lang="da" onclick="pickLang('da',this)">&#127465;&#127472; Dansk</button>
278+
<button class="lang-btn" data-lang="fi" onclick="pickLang('fi',this)">&#127467;&#127470; Suomi</button>
279+
<button class="lang-btn" data-lang="no" onclick="pickLang('no',this)">&#127475;&#127476; Norsk</button>
280+
<button class="lang-btn" data-lang="ro" onclick="pickLang('ro',this)">&#127479;&#127476; Rom&acirc;n&#259;</button>
281+
<button class="lang-btn" data-lang="el" onclick="pickLang('el',this)">&#127468;&#127479; &Epsilon;&lambda;&lambda;&eta;&nu;&iota;&kappa;&#940;</button>
269282
</div>
270283

271284
<div class="setup-cam">
@@ -306,7 +319,7 @@ <h2>Screen Sharing Required</h2>
306319
<div style="width:80px;height:80px;font-size:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(99,102,241,0.1);color:var(--primary);margin-bottom:24px;border:2px solid rgba(99,102,241,0.3);animation:pulse 1s infinite">
307320
<i class="fas fa-circle-notch fa-spin"></i>
308321
</div>
309-
<h2 style="color:#fff;font-weight:900;font-size:28px">Finalizing Assessment</h2>
322+
<h2 style="color:#fff;font-weight:900;font-size:28px">Finalizing Assessment</h2>
310323
<p style="color:var(--dim);margin-top:12px;font-size:15px;font-weight:500">AI is evaluating your responses, technical depth, and proctoring logs.</p>
311324
</div>
312325

@@ -355,15 +368,15 @@ <h2>Screen Sharing Required</h2>
355368
<div class="score-ticker">
356369
<div class="q-label" id="qOverlay">Q 0</div>
357370
<div class="mini-scores">
358-
<div class="ms ms-t" id="tScore">T</div>
359-
<div class="ms ms-b" id="bScore">B</div>
360-
<div class="ms ms-p" id="pScore">P</div>
371+
<div class="ms ms-t" id="tScore">T</div>
372+
<div class="ms ms-b" id="bScore">B</div>
373+
<div class="ms ms-p" id="pScore">P</div>
361374
</div>
362375
</div>
363376
<div class="viz-wrap" id="vizWrap"></div>
364377
<div class="orb-wrap">
365378
<div class="orb" id="orb">??</div>
366-
<div class="status-text" id="statusTxt">CONNECTING</div>
379+
<div class="status-text" id="statusTxt">CONNECTING</div>
367380
<div class="subtitle" id="subtitle"></div>
368381
</div>
369382
<div class="self-cam">
@@ -403,10 +416,23 @@ <h2>Screen Sharing Required</h2>
403416
ml:{s:'ml-IN',t:'ml-IN',n:'Malayalam'},
404417
ta:{s:'ta-IN',t:'ta-IN',n:'Tamil'},
405418
ar:{s:'ar-SA',t:'ar-SA',n:'Arabic'},
419+
fr:{s:'fr-FR',t:'fr-FR',n:'French'},
420+
de:{s:'de-DE',t:'de-DE',n:'German'},
421+
es:{s:'es-ES',t:'es-ES',n:'Spanish'},
422+
pt:{s:'pt-PT',t:'pt-PT',n:'Portuguese'},
423+
it:{s:'it-IT',t:'it-IT',n:'Italian'},
424+
nl:{s:'nl-NL',t:'nl-NL',n:'Dutch'},
425+
pl:{s:'pl-PL',t:'pl-PL',n:'Polish'},
426+
sv:{s:'sv-SE',t:'sv-SE',n:'Swedish'},
427+
da:{s:'da-DK',t:'da-DK',n:'Danish'},
428+
fi:{s:'fi-FI',t:'fi-FI',n:'Finnish'},
429+
no:{s:'nb-NO',t:'nb-NO',n:'Norwegian'},
430+
ro:{s:'ro-RO',t:'ro-RO',n:'Romanian'},
431+
el:{s:'el-GR',t:'el-GR',n:'Greek'},
406432
};
407433

408434
// ----------------------------------------------------------------
409-
// GUARD No Token
435+
// GUARD No Token
410436
// ----------------------------------------------------------------
411437
if(!urlToken){
412438
document.addEventListener('DOMContentLoaded',()=>{
@@ -506,14 +532,18 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:10px;font-weight:800">Acce
506532
// AI CALL
507533
// ----------------------------------------------------------------
508534
async function aiCall(messages, maxTokens=600){
509-
const r = await fetch(W+'/api/interview',{
535+
const tenantId = interviewMeta?.company_id || 'default';
536+
const r = await fetch(W+'/ai/interview-question',{
510537
method:'POST',
511-
headers:{'Content-Type':'application/json'},
512-
body:JSON.stringify({messages, model:'llama-3.3-70b-versatile', max_tokens:maxTokens})
538+
headers:{
539+
'Content-Type':'application/json',
540+
'X-Tenant-ID': tenantId
541+
},
542+
body:JSON.stringify({messages, token: curToken, max_tokens:maxTokens})
513543
});
514544
if(!r.ok) throw new Error('AI '+r.status);
515545
const d = await r.json();
516-
return d.choices?.[0]?.message?.content || d.content || d.response || '';
546+
return d.data?.response || d.response || d.choices?.[0]?.message?.content || d.content || '';
517547
}
518548

519549
// ----------------------------------------------------------------
@@ -523,7 +553,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:10px;font-weight:800">Acce
523553
const file = e.target.files[0]; if(!file) return;
524554
const zone = document.getElementById('resumeDropZone');
525555
const lbl = document.getElementById('resumeLabel');
526-
lbl.innerHTML='<i class="fas fa-spinner fa-spin"></i> Reading '+file.name+'';
556+
lbl.innerHTML='<i class="fas fa-spinner fa-spin"></i> Reading '+file.name+'';
527557
try{
528558
if(file.type==='application/pdf'||file.name.endsWith('.pdf')){
529559
if(window.pdfjsLib){
@@ -551,9 +581,9 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:10px;font-weight:800">Acce
551581
resumeText = (await file.text()).substring(0,4000);
552582
}
553583
zone.classList.add('loaded');
554-
lbl.innerHTML='<i class="fas fa-check-circle"></i> Resume loaded '+file.name;
584+
lbl.innerHTML='<i class="fas fa-check-circle"></i> Resume loaded '+file.name;
555585
} catch(err){
556-
lbl.innerHTML='?? Could not read file enter background manually';
586+
lbl.innerHTML='?? Could not read file enter background manually';
557587
}
558588
}
559589

@@ -790,7 +820,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
790820
return;
791821
}
792822

793-
// Browser STT continuous, hot mic
823+
// Browser STT continuous, hot mic
794824
const SR = window.SpeechRecognition||window.webkitSpeechRecognition;
795825
if(SR){
796826
recognition = new SR();
@@ -818,7 +848,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
818848
const totalWords = liveTranscript.trim().split(/\s+/).filter(Boolean).length;
819849
baseSilenceMS = totalWords>20 ? 4000 : 2600;
820850
const disp = interim||liveTranscript.trim().split(' ').slice(-5).join(' ');
821-
setStatus(`LISTENING · "${disp}"`);
851+
setStatus(`LISTENING "${disp}"`);
822852
}
823853
};
824854

@@ -850,9 +880,9 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
850880
liveTranscript = '';
851881
audioChunks = [];
852882
setOrb('interrupted');
853-
setStatus('INTERRUPTED · LISTENING');
883+
setStatus('INTERRUPTED LISTENING');
854884
document.getElementById('subtitle').classList.remove('show');
855-
toast('<i class="fas fa-microphone-lines"></i> Interruption detected go ahead!');
885+
toast('<i class="fas fa-microphone-lines"></i> Interruption detected go ahead!');
856886
setTimeout(()=>{ if(!isSpeaking) setOrb('listening'); },800);
857887
}
858888

@@ -958,11 +988,11 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
958988
? `No background provided. Ask general ${S.jobLevel}-level questions for "${S.jobTitle}".`
959989
: `
960990
CANDIDATE BACKGROUND:
961-
Technologies: ${candidateProfile.technologies.join(', ')||'not specified'}
962-
Experience: ${candidateProfile.yearsExperience ? candidateProfile.yearsExperience+' years' : 'not stated'}
963-
Seniority: ${candidateProfile.seniorityHints.join(', ')||'not stated'}
964-
Projects built: ${candidateProfile.projects.join(' | ')||'none extracted'}
965-
Raw context: ${candidateProfile.rawText.substring(0,900)}
991+
Technologies: ${candidateProfile.technologies.join(', ')||'not specified'}
992+
Experience: ${candidateProfile.yearsExperience ? candidateProfile.yearsExperience+' years' : 'not stated'}
993+
Seniority: ${candidateProfile.seniorityHints.join(', ')||'not stated'}
994+
Projects built: ${candidateProfile.projects.join(' | ')||'none extracted'}
995+
Raw context: ${candidateProfile.rawText.substring(0,900)}
966996
`.trim();
967997

968998
const uncovered = candidateProfile.technologies.filter(t=>!S.skillsCovered.has(t)).slice(0,5);
@@ -971,7 +1001,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
9711001
warmup:`
9721002
ASK a warm personalised opener referencing ONE specific technology or project from their background.
9731003
BAD: "Tell me about yourself."
974-
GOOD: "I see you've worked with ${candidateProfile.technologies[0]||'various technologies'} what first drew you to that ecosystem?"
1004+
GOOD: "I see you've worked with ${candidateProfile.technologies[0]||'various technologies'} what first drew you to that ecosystem?"
9751005
`,
9761006
technical:`
9771007
ASK a deep technical question targeting one of: [${uncovered.join(', ')||'core technical fundamentals'}].
@@ -999,10 +1029,10 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
9991029
Last answer: "${S.history.filter(h=>h.role==='candidate').slice(-1)[0]?.content?.substring(0,400)||''}"
10001030
10011031
Probe ONE of:
1002-
Quantify: "What was the measurable impact of that?"
1003-
Specifics: "Walk me through the exact implementation of [X they mentioned]."
1004-
Deeper: "How would you approach that differently today?"
1005-
Catch vague: "You mentioned [X] what specifically did that involve?"
1032+
Quantify: "What was the measurable impact of that?"
1033+
Specifics: "Walk me through the exact implementation of [X they mentioned]."
1034+
Deeper: "How would you approach that differently today?"
1035+
Catch vague: "You mentioned [X] what specifically did that involve?"
10061036
Be conversational, not interrogative.
10071037
` : '';
10081038

@@ -1011,8 +1041,8 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
10111041
.join('\n');
10121042

10131043
const sys = `You are an elite AI technical interviewer for "${S.jobTitle||'Professional Role'}" at ${S.jobLevel} level.
1014-
Language: ${LANG[selectedLang]?.n||'English'} respond ENTIRELY in this language.
1015-
Phase: ${phase.toUpperCase()} Q${S.questionCount+1} of ${S.maxQuestions}
1044+
Language: ${LANG[selectedLang]?.n||'English'} respond ENTIRELY in this language.
1045+
Phase: ${phase.toUpperCase()} Q${S.questionCount+1} of ${S.maxQuestions}
10161046
10171047
${profileCtx}
10181048
@@ -1023,7 +1053,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
10231053
1. Output EXACTLY ONE question. Max 2 sentences.
10241054
2. NEVER repeat a topic already covered: [${[...S.skillsCovered].join(', ')||'none'}]
10251055
3. NEVER offer feedback, praise, or advice ("Great answer!" is forbidden).
1026-
4. Make it personal reference their ACTUAL background.
1056+
4. Make it personal reference their ACTUAL background.
10271057
5. Sound like a thoughtful senior engineer / hiring manager.
10281058
6. No labels, no prefixes, no quotes around output.
10291059
@@ -1147,7 +1177,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
11471177
}
11481178

11491179
// ----------------------------------------------------------------
1150-
// TTS INTERRUPTIBLE CHUNKED SPEECH
1180+
// TTS INTERRUPTIBLE CHUNKED SPEECH
11511181
// ----------------------------------------------------------------
11521182
function makeUtt(text,cb){
11531183
const u=new SpeechSynthesisUtterance(text);
@@ -1225,7 +1255,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
12251255
submitAnswer();
12261256
} else if(words>=MIN_WORDS && elapsed>1000){
12271257
const remaining=Math.max(0,baseSilenceMS-elapsed);
1228-
setStatus(`LISTENING · ${words} words · submitting in ${(remaining/1000).toFixed(1)}s`);
1258+
setStatus(`LISTENING ${words} words submitting in ${(remaining/1000).toFixed(1)}s`);
12291259
}
12301260
}
12311261
setTimeout(loop,200);
@@ -1502,7 +1532,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
15021532
let summary='Interview completed.', strengths=[], improvements=[];
15031533

15041534
try{
1505-
// AI evaluates answers stored server-side only
1535+
// AI evaluates answers stored server-side only
15061536
const answerTexts = S.history
15071537
.filter(h=>h.role==='candidate')
15081538
.map((h,i)=>`A${i+1}: ${h.content.substring(0,350)}`)
@@ -1532,7 +1562,7 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
15321562
summary,
15331563
strengths: JSON.stringify(strengths),
15341564
improvements: JSON.stringify(improvements),
1535-
// Transcripts saved for HR eyes only never shown in candidate UI
1565+
// Transcripts saved for HR eyes only never shown in candidate UI
15361566
answers: JSON.stringify(S.history.filter(h=>h.role==='candidate').map(h=>h.content)),
15371567
full_transcript: JSON.stringify(S.history),
15381568
violation_log: JSON.stringify(violations),
@@ -1564,16 +1594,16 @@ <h2 style="color:#f8fafc;font-size:22px;margin-bottom:12px;font-weight:800">${ms
15641594
15651595
<div style="font-size:52px;margin-bottom:12px">${finalScore>=65?'??':'??'}</div>
15661596
<h2 style="font-size:26px;font-weight:900;margin-bottom:6px;letter-spacing:-0.5px">Assessment Complete</h2>
1567-
<p style="color:var(--dim);font-size:13px;margin-bottom:28px;font-weight:500">AI-Powered Evaluation · SimpaticoHR Enterprise</p>
1597+
<p style="color:var(--dim);font-size:13px;margin-bottom:28px;font-weight:500">AI-Powered Evaluation SimpaticoHR Enterprise</p>
15681598
15691599
<!-- Score -->
15701600
<div style="background:rgba(255,255,255,0.02);border-radius:16px;padding:28px;margin-bottom:20px;border:1px solid rgba(255,255,255,0.05)">
15711601
<div style="font-size:72px;font-weight:900;color:${col};line-height:1;text-shadow:0 0 40px ${col};">${finalScore}<span style="font-size:32px">%</span></div>
15721602
<div style="font-size:15px;font-weight:800;color:${col};margin:12px 0;letter-spacing:0.5px;text-transform:uppercase">${rec}</div>
15731603
<div style="font-size:12px;color:var(--dim);font-weight:600">
15741604
Grade: <strong style="color:#fff">${grade}</strong>
1575-
&nbsp;·&nbsp; Trust: <strong style="color:#fff">${trust}%</strong>
1576-
&nbsp;·&nbsp; Violations: <strong style="color:${violations.length>0?'#fca5a5':'#6ee7b7'}">${violations.length}</strong>
1605+
&nbsp;&nbsp; Trust: <strong style="color:#fff">${trust}%</strong>
1606+
&nbsp;&nbsp; Violations: <strong style="color:${violations.length>0?'#fca5a5':'#6ee7b7'}">${violations.length}</strong>
15771607
${vPenalty>0?`<span style="color:#fca5a5;font-size:11px"> (-${vPenalty} pts)</span>`:''}
15781608
</div>
15791609
</div>

0 commit comments

Comments
 (0)