0
TOOLS#11981bab
Recruitment Dashboard
@Owner·deposited 2w ago·updated 2w ago·84 views
TOOLS#11981bab
Recruitment Dashboard
OW
@Owner
84Views
0Comments
0Forks
0Saves
SHARE · REMIX
Recruitment Dashboard — a HTML tools widget by @Owner.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.loadvibes.shared.setvibes.shared.get
- Global shared room: Room "recruitment-board" is hardcoded — every viewer shares the same state.
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "Recruitment Dashboard" by @Owner]
Source: https://itjustvibes.com/Owner/recruitment-dashboard
Type: HTML
--- SOURCE CODE ---
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recruitment Dashboard</title>
<style>
:root{
--bg:#f7f7f8; --panel:#ffffff; --border:#e3e3e8; --text:#1a1a24; --muted:#6b6b7a;
--accent:#4f5de0; --accent-soft:#eef0fd; --danger:#c94040; --warn:#b8860b; --ok:#2e8b57;
--chip:#eceef2; --shadow:0 1px 3px rgba(0,0,0,.07);
}
@media (prefers-color-scheme: dark){
:root{ --bg:#15151c; --panel:#1e1e27; --border:#32323e; --text:#e8e8ee; --muted:#9a9aab;
--accent:#7b87f0; --accent-soft:#272a44; --chip:#2a2a36; --shadow:0 1px 3px rgba(0,0,0,.4); }
}
*{box-sizing:border-box;margin:0;padding:0}
body{font:13px/1.45 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text)}
button{font:inherit;cursor:pointer;border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:6px;padding:4px 10px}
button:hover{border-color:var(--accent)}
button.primary{background:var(--accent);border-color:var(--accent);color:#fff}
button.ghost{border:none;background:none;padding:2px 6px;color:var(--muted)}
button.ghost:hover{color:var(--text)}
button.danger{color:var(--danger)}
input,select,textarea{font:inherit;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:5px 8px}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
textarea{resize:vertical}
label{font-size:11px;color:var(--muted);display:block;margin-bottom:2px}
header{display:flex;align-items:center;gap:12px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap;position:sticky;top:0;z-index:20}
header h1{font-size:16px;font-weight:600;margin-right:4px}
.presence{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.avatars{display:flex}
.avatar{width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:700;border:2px solid var(--panel);margin-left:-6px}
.avatar:first-child{margin-left:0}
.avatar.sm{width:18px;height:18px;font-size:9px;border-width:1px}
.spacer{flex:1}
#search{width:220px}
.toolbar{display:flex;align-items:center;gap:8px;padding:8px 16px;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--panel)}
.toolbar select{max-width:150px}
.chips{display:flex;gap:6px;flex-wrap:wrap;padding:0 16px 8px;background:var(--panel)}
.chips:empty{display:none;padding:0}
.chip{display:inline-flex;align-items:center;gap:5px;background:var(--accent-soft);color:var(--accent);border-radius:20px;padding:2px 10px;font-size:12px}
.chip b{cursor:pointer}
.layout{display:flex;gap:0;align-items:flex-start}
main{flex:1;padding:14px 16px;min-width:0}
aside{width:300px;flex-shrink:0;border-left:1px solid var(--border);background:var(--panel);padding:12px;position:sticky;top:96px;max-height:calc(100vh - 96px);overflow-y:auto}
aside.hidden{display:none}
.tabs{display:flex;gap:4px;margin-bottom:10px}
.tabs button{flex:1}
.tabs button.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent)}
.bucket{background:var(--panel);border:1px solid var(--border);border-radius:10px;margin-bottom:14px;box-shadow:var(--shadow)}
.bucket-head{display:flex;align-items:center;gap:8px;padding:10px 12px;flex-wrap:wrap}
.bucket-head h2{font-size:14px;font-weight:600}
.bucket-meta{font-size:11px;color:var(--muted)}
.pill{font-size:10px;padding:1px 7px;border-radius:10px;background:var(--chip);color:var(--muted)}
.pill.hi{background:#fde8e8;color:#b03030}
@media (prefers-color-scheme: dark){.pill.hi{background:#4a2626;color:#f0a0a0}}
.stage-mini{display:flex;gap:4px;flex-wrap:wrap;font-size:10px;color:var(--muted)}
.stage-mini span{background:var(--chip);border-radius:8px;padding:0 6px}
.bucket-body{padding:0 12px 12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px}
.bucket.collapsed .bucket-body,.bucket.collapsed .quickadd{display:none}
.card{border:1px solid var(--border);border-radius:8px;padding:8px 10px;cursor:pointer;background:var(--bg)}
.card:hover{border-color:var(--accent)}
.card.archived{opacity:.55}
.card .name{font-weight:600;display:flex;align-items:center;gap:5px}
.card .sub{font-size:11px;color:var(--muted);margin:1px 0 5px}
.card .row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px}
.badge{font-size:10px;padding:1px 7px;border-radius:9px;font-weight:600}
.attr{font-size:10px;color:var(--muted);margin-top:5px}
.flag{color:var(--warn);font-size:10px;font-weight:700}
.star{color:#d4a017}
.quickadd{padding:0 12px 12px}
.quickadd input{width:100%;max-width:280px}
.notes-item,.feed-item{border-bottom:1px solid var(--border);padding:7px 0;font-size:12px}
.notes-item .meta,.feed-item .meta{font-size:10px;color:var(--muted);margin-top:2px}
.notes-item.pinned{background:var(--accent-soft);border-radius:6px;padding:7px 8px}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:50;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}
.modal{background:var(--panel);border-radius:12px;padding:18px;width:100%;max-width:640px;box-shadow:0 8px 30px rgba(0,0,0,.25)}
.modal h3{font-size:15px;margin-bottom:12px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.fgrid .full{grid-column:1/-1}
.mrow{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.tl-item{border-left:2px solid var(--border);padding:4px 0 4px 10px;margin-left:4px;font-size:12px}
.tl-item .meta{font-size:10px;color:var(--muted)}
details summary{cursor:pointer;font-size:12px;color:var(--muted);margin:8px 0}
.hist-item{font-size:11px;color:var(--muted);padding:2px 0}
.empty{color:var(--muted);text-align:center;padding:30px;font-size:13px}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:8px 16px;border-radius:8px;font-size:12px;z-index:99;opacity:0;transition:opacity .25s;pointer-events:none}
#toast.show{opacity:1}
@media (max-width:760px){aside{display:none}}
</style>
</head>
<body>
<div id="app"><div class="empty">Loading...</div></div>
<div id="modal"></div>
<div id="toast"></div>
<script>
'use strict';
/* ---------- constants ---------- */
const STAGES=['Sourced','Contacted','Responded','Screening','Interviewing','Offer','Hired','Passed','Rejected'];
const TERMINAL=['Hired','Passed','Rejected'];
const STAGE_COLORS={Sourced:'#8888a0',Contacted:'#5b8dd6',Responded:'#3aa6a6',Screening:'#9b6dd6',Interviewing:'#d68f3a',Offer:'#d63a8f',Hired:'#2e8b57',Passed:'#777',Rejected:'#c94040'};
const TP_TYPES=['email','call','InMail','referral','meeting','event','other'];
const TP_OUTCOMES=['no-response','positive','neutral','negative','scheduled'];
const STALE_DAYS=14;
const AVATAR_COLORS=['#4f5de0','#d63a8f','#2e8b57','#d68f3a','#9b6dd6','#3aa6a6','#c94040','#5b8dd6'];
/* ---------- state ---------- */
let me=null, userCount=1, sidebarTab='notes', sidebarOpen=true, openCand=null, renderTimer=null, prefsTimer=null;
let F={q:'',bucket:'',stage:'',owner:'',tag:'',contributor:'',priority:false,followup:false,showArchived:false,sort:'activity'};
/* ---------- helpers ---------- */
const $=s=>document.querySelector(s);
const uid=()=>crypto.randomUUID();
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function rel(ts){if(!ts)return'';const d=Date.now()-ts,m=Math.floor(d/60000);if(m<1)return'just now';if(m<60)return m+'m ago';const h=Math.floor(m/60);if(h<24)return h+'h ago';const dd=Math.floor(h/24);if(dd<30)return dd+'d ago';return new Date(ts).toLocaleDateString();}
function daysSince(ts){return Math.floor((Date.now()-ts)/86400000);}
function colorFor(id){let h=0;for(const c of String(id))h=(h*31+c.charCodeAt(0))>>>0;return AVATAR_COLORS[h%AVATAR_COLORS.length];}
function initials(n){return String(n||'?').trim().split(/\s+/).map(w=>w[0]).slice(0,2).join('').toUpperCase();}
function avatar(userId,name,sm){const r=G('team-roster')||{};const col=(r[userId]&&r[userId].color)||colorFor(userId||name);return '<span class="avatar'+(sm?' sm':'')+'" style="background:'+col+'" title="'+esc(name)+'">'+esc(initials(name))+'</span>';}
function toast(msg){const t=$('#toast');t.textContent=msg;t.classList.add('show');clearTimeout(toast._t);toast._t=setTimeout(()=>t.classList.remove('show'),2600);}
/* ---------- shared state access ---------- */
const G=k=>{try{return vibes.shared.get(k);}catch(e){return null;}};
async function put(k,v){try{await vibes.shared.set(k,v);}catch(e){console.error('set failed',k,e);toast('Save failed — please retry');throw e;}}
/* ---------- audit ---------- */
const who=()=>({userId:me.userId,name:me.name});
function auditNew(o){const at=Date.now();o.createdBy=who();o.createdAt=at;o.lastEditedBy=who();o.lastEditedAt=at;o.history=[{...who(),action:'created',at}];}
function auditEdit(o,changes){const at=Date.now();o.lastEditedBy=who();o.lastEditedAt=at;o.history=((o.history||[]).concat(changes.map(c=>({...who(),action:c.action||'edited',field:c.field,from:c.from,to:c.to,at})))).slice(-50);}
function diff(o,fields){const ch=[];for(const[f,v]of Object.entries(fields)){const old=o[f];if(JSON.stringify(old)!==JSON.stringify(v)){ch.push({field:f,from:Array.isArray(old)?old.join(','):old,to:Array.isArray(v)?v.join(','):v});o[f]=v;}}return ch;}
async function logAct(action,label){try{const feed=(G('activity:feed')||[]).slice();feed.unshift({id:uid(),byName:me.name,action,targetLabel:label,at:Date.now()});await put('activity:feed',feed.slice(0,100));}catch(e){}}
/* ---------- entity ops (read-modify-write, granular keys) ---------- */
async function addBucket(fields){const b={id:uid(),title:'',description:'',level:'',location:'',hiringManager:'',priority:'medium',targetCount:0,archived:false,...fields};auditNew(b);await put('bucket:'+b.id,b);const idx=(G('buckets:index')||[]).slice();idx.push(b.id);await put('buckets:index',idx);await logAct('created bucket',b.title);}
async function saveBucket(id,fields){const b={...G('bucket:'+id)};if(!b.id)return;const ch=diff(b,fields);if(!ch.length)return;auditEdit(b,ch);await put('bucket:'+id,b);await logAct('edited bucket',b.title);}
async function setBucketArchived(id,val){const b={...G('bucket:'+id)};if(!b.id)return;b.archived=val;auditEdit(b,[{action:val?'archived':'restored'}]);await put('bucket:'+id,b);await logAct((val?'archived':'restored')+' bucket',b.title);}
async function moveBucket(id,dir){const idx=(G('buckets:index')||[]).slice();const i=idx.indexOf(id),j=i+dir;if(i<0||j<0||j>=idx.length)return;[idx[i],idx[j]]=[idx[j],idx[i]];await put('buckets:index',idx);}
async function hardDeleteBucket(id){const b=G('bucket:'+id)||{};const cands=allCandidates().filter(c=>c.bucketId===id);if(cands.length){toast('Move or delete its candidates first');return;}await put('buckets:index',(G('buckets:index')||[]).filter(x=>x!==id));await put('bucket:'+id,null);await logAct('deleted bucket',b.title||'');}
async function addCandidate(bucketId,name){const c={id:uid(),bucketId,name,currentTitle:'',currentCompany:'',links:{linkedin:'',email:'',phone:''},stage:'Sourced',source:'',ownerUserId:'',priority:false,tags:[],summaryNote:'',touchpointIds:[],archived:false};auditNew(c);await put('candidate:'+c.id,c);const idx=(G('candidates:index')||[]).slice();idx.push(c.id);await put('candidates:index',idx);await logAct('added candidate',name);}
async function saveCandidate(id,fields){const c={...G('candidate:'+id)};if(!c.id)return;const ch=diff(c,fields);if(!ch.length)return;auditEdit(c,ch);await put('candidate:'+id,c);await logAct('edited candidate',c.name);}
async function setCandArchived(id,val){const c={...G('candidate:'+id)};if(!c.id)return;c.archived=val;auditEdit(c,[{action:val?'archived':'restored'}]);await put('candidate:'+id,c);await logAct((val?'archived':'restored')+' candidate',c.name);}
async function hardDeleteCand(id){const c=G('candidate:'+id)||{};await put('candidates:index',(G('candidates:index')||[]).filter(x=>x!==id));for(const t of(c.touchpointIds||[]))await put('touchpoint:'+t,null);await put('candidate:'+id,null);await logAct('deleted candidate',c.name||'');}
async function addTouchpoint(candId,tp){tp.id=uid();tp.candidateId=candId;tp.byUserId=me.userId;tp.byName=me.name;tp.createdAt=Date.now();await put('touchpoint:'+tp.id,tp);const c={...G('candidate:'+candId)};c.touchpointIds=(c.touchpointIds||[]).concat(tp.id);auditEdit(c,[{action:'logged '+tp.type+' touchpoint'}]);await put('candidate:'+candId,c);await logAct('logged '+tp.type,c.name);}
async function addNote(text){const notes=(G('team-notes')||[]).slice();notes.unshift({id:uid(),text,byUserId:me.userId,byName:me.name,at:Date.now(),pinned:false});await put('team-notes',notes);await logAct('added team note','');}
async function togglePin(noteId){const notes=(G('team-notes')||[]).map(n=>n.id===noteId?{...n,pinned:!n.pinned}:n);await put('team-notes',notes);}
/* ---------- derived ---------- */
function allBuckets(){return(G('buckets:index')||[]).map(id=>G('bucket:'+id)).filter(Boolean);}
function allCandidates(){return(G('candidates:index')||[]).map(id=>G('candidate:'+id)).filter(Boolean);}
function touchpoints(c){return(c.touchpointIds||[]).map(id=>G('touchpoint:'+id)).filter(Boolean).sort((a,b)=>(b.date||b.createdAt)-(a.date||a.createdAt));}
function lastTouchAt(c){const t=touchpoints(c)[0];return t?(t.date||t.createdAt):null;}
function needsFollowup(c){if(TERMINAL.includes(c.stage)||c.archived)return false;const lt=lastTouchAt(c)||c.createdAt;return daysSince(lt)>STALE_DAYS;}
function allTags(){const s=new Set();allCandidates().forEach(c=>(c.tags||[]).forEach(t=>s.add(t)));return[...s].sort();}
function lastActivity(c){return Math.max(c.lastEditedAt||0,lastTouchAt(c)||0);}
function candMatches(c){
if(!F.showArchived&&c.archived)return false;
if(F.q){const q=F.q.toLowerCase();const hay=[c.name,c.currentCompany,c.currentTitle,c.summaryNote,...touchpoints(c).map(t=>t.note)].join(' ').toLowerCase();if(!hay.includes(q))return false;}
if(F.bucket&&c.bucketId!==F.bucket)return false;
if(F.stage&&c.stage!==F.stage)return false;
if(F.owner&&c.ownerUserId!==F.owner)return false;
if(F.tag&&!(c.tags||[]).includes(F.tag))return false;
if(F.contributor&&!(c.createdBy&&c.createdBy.userId===F.contributor)&&!(c.lastEditedBy&&c.lastEditedBy.userId===F.contributor))return false;
if(F.priority&&!c.priority)return false;
if(F.followup&&!needsFollowup(c))return false;
return true;
}
function sortCands(list){
if(F.sort==='name')return list.sort((a,b)=>a.name.localeCompare(b.name));
if(F.sort==='stage')return list.sort((a,b)=>STAGES.indexOf(a.stage)-STAGES.indexOf(b.stage));
return list.sort((a,b)=>lastActivity(b)-lastActivity(a));
}
/* ---------- prefs ---------- */
function savePrefs(){clearTimeout(prefsTimer);prefsTimer=setTimeout(async()=>{try{await vibes.save('prefs',F);}catch(e){}},1200);}
/* ---------- render ---------- */
function scheduleRender(){clearTimeout(renderTimer);renderTimer=setTimeout(()=>{render();refreshModalDynamic();},80);}
function render(){
const ae=document.activeElement;const keep=ae&&ae.id&&$('#app').contains(ae)?{id:ae.id,value:ae.value,ss:ae.selectionStart,se:ae.selectionEnd}:null;
$('#app').innerHTML=headerHTML()+toolbarHTML()+chipsHTML()+'<div class="layout"><main>'+boardHTML()+'</main>'+sidebarHTML()+'</div>';
if(keep){const el=document.getElementById(keep.id);if(el){el.value=keep.value;el.focus();try{el.setSelectionRange(keep.ss,keep.se);}catch(e){}}}
}
function headerHTML(){
const roster=G('team-roster')||{};
const avs=Object.entries(roster).slice(0,8).map(([id,u])=>avatar(id,u.name)).join('');
return '<header><h1>Recruitment Dashboard</h1>'
+'<span class="presence"><span class="avatars">'+avs+'</span> '+userCount+' online</span>'
+'<span class="spacer"></span>'
+'<input id="search" type="search" placeholder="Search candidates, companies, notes..." value="'+esc(F.q)+'" data-filter="q">'
+'<button data-act="add-bucket" class="primary">+ Role bucket</button>'
+'<button data-act="export">Export</button>'
+'<button data-act="toggle-sidebar">'+(sidebarOpen?'Hide':'Show')+' panel</button>'
+'</header>';
}
function toolbarHTML(){
const roster=G('team-roster')||{};
const opt=(v,l,cur)=>'<option value="'+esc(v)+'"'+(v===cur?' selected':'')+'>'+esc(l)+'</option>';
return '<div class="toolbar">'
+'<select data-filter="bucket"><option value="">All buckets</option>'+allBuckets().filter(b=>!b.archived).map(b=>opt(b.id,b.title,F.bucket)).join('')+'</select>'
+'<select data-filter="stage"><option value="">All stages</option>'+STAGES.map(s=>opt(s,s,F.stage)).join('')+'</select>'
+'<select data-filter="owner"><option value="">Any owner</option>'+Object.entries(roster).map(([id,u])=>opt(id,u.name,F.owner)).join('')+'</select>'
+'<select data-filter="tag"><option value="">Any tag</option>'+allTags().map(t=>opt(t,t,F.tag)).join('')+'</select>'
+'<select data-filter="contributor"><option value="">Any contributor</option>'+Object.entries(roster).map(([id,u])=>opt(id,u.name,F.contributor)).join('')+'</select>'
+'<label style="display:inline;font-size:12px"><input type="checkbox" data-filter="priority"'+(F.priority?' checked':'')+'> Priority</label>'
+'<label style="display:inline;font-size:12px"><input type="checkbox" data-filter="followup"'+(F.followup?' checked':'')+'> Needs follow-up</label>'
+'<label style="display:inline;font-size:12px"><input type="checkbox" data-filter="showArchived"'+(F.showArchived?' checked':'')+'> Show archived</label>'
+'<span class="spacer"></span>'
+'<select data-filter="sort">'+opt('activity','Sort: last activity',F.sort)+opt('name','Sort: name',F.sort)+opt('stage','Sort: stage',F.sort)+'</select>'
+'</div>';
}
function chipsHTML(){
const roster=G('team-roster')||{};const chips=[];
const add=(k,label)=>chips.push('<span class="chip">'+esc(label)+' <b data-clear="'+k+'">✕</b></span>');
if(F.q)add('q','"'+F.q+'"');
if(F.bucket){const b=G('bucket:'+F.bucket);add('bucket','Bucket: '+(b?b.title:'?'));}
if(F.stage)add('stage','Stage: '+F.stage);
if(F.owner)add('owner','Owner: '+((roster[F.owner]||{}).name||'?'));
if(F.tag)add('tag','Tag: '+F.tag);
if(F.contributor)add('contributor','By: '+((roster[F.contributor]||{}).name||'?'));
if(F.priority)add('priority','Priority');
if(F.followup)add('followup','Needs follow-up');
if(F.showArchived)add('showArchived','Archived shown');
if(chips.length>1)chips.push('<span class="chip" style="cursor:pointer" data-clear="*">clear all</span>');
return '<div class="chips">'+chips.join('')+'</div>';
}
function stageBadge(s){const c=STAGE_COLORS[s]||'#888';return '<span class="badge" style="background:'+c+'22;color:'+c+'">'+esc(s)+'</span>';}
function cardHTML(c){
const roster=G('team-roster')||{};const owner=roster[c.ownerUserId];
const lt=lastTouchAt(c);
return '<div class="card'+(c.archived?' archived':'')+'" data-open="'+c.id+'">'
+'<div class="name">'+(c.priority?'<span class="star">★</span>':'')+esc(c.name)+(needsFollowup(c)?' <span class="flag">⚑ follow up</span>':'')+'</div>'
+'<div class="sub">'+esc(c.currentTitle||'')+(c.currentTitle&&c.currentCompany?' @ ':'')+esc(c.currentCompany||'')+'</div>'
+'<div class="row">'+stageBadge(c.stage)
+(owner?avatar(c.ownerUserId,owner.name,true):'')
+(c.tags||[]).slice(0,3).map(t=>'<span class="pill">'+esc(t)+'</span>').join('')
+'</div>'
+'<div class="attr">'+(lt?'Last contact '+daysSince(lt)+'d ago':'Never contacted')
+' · Added by '+esc((c.createdBy||{}).name||'?')
+(c.lastEditedBy&&c.lastEditedAt!==c.createdAt?' · edited by '+esc(c.lastEditedBy.name)+' '+rel(c.lastEditedAt):'')+'</div>'
+'</div>';
}
function bucketHTML(b,cands){
const active=cands.filter(c=>!c.archived);
const byStage=STAGES.map(s=>{const n=active.filter(c=>c.stage===s).length;return n?'<span>'+esc(s)+' '+n+'</span>':'';}).join('');
return '<section class="bucket'+(b._collapsed?' collapsed':'')+'" data-bucket="'+b.id+'">'
+'<div class="bucket-head">'
+'<button class="ghost" data-act="collapse" data-id="'+b.id+'">'+(b._collapsed?'▸':'▾')+'</button>'
+'<h2>'+esc(b.title)+'</h2>'
+'<span class="pill'+(b.priority==='high'?' hi':'')+'">'+esc(b.priority)+'</span>'
+'<span class="bucket-meta">'+active.length+(b.targetCount?' / '+b.targetCount:'')+' candidates'
+(b.hiringManager?' · HM: '+esc(b.hiringManager):'')+(b.location?' · '+esc(b.location):'')+'</span>'
+'<div class="stage-mini">'+byStage+'</div>'
+'<span class="spacer"></span>'
+'<button class="ghost" data-act="bucket-up" data-id="'+b.id+'" title="Move up">↑</button>'
+'<button class="ghost" data-act="bucket-down" data-id="'+b.id+'" title="Move down">↓</button>'
+'<button class="ghost" data-act="edit-bucket" data-id="'+b.id+'">Edit</button>'
+'<button class="ghost danger" data-act="archive-bucket" data-id="'+b.id+'">Archive</button>'
+'</div>'
+'<div class="bucket-body">'+(cands.length?cands.map(cardHTML).join(''):'<div class="empty" style="grid-column:1/-1;padding:12px">No candidates'+(F.q||F.stage||F.owner||F.tag?' match the filters':'')+'.</div>')+'</div>'
+'<div class="quickadd"><input id="qa-'+b.id+'" placeholder="Quick add: candidate name, press Enter" data-quickadd="'+b.id+'"></div>'
+'</section>';
}
function boardHTML(){
const buckets=allBuckets();
const live=buckets.filter(b=>!b.archived&&(!F.bucket||b.id===F.bucket));
if(!buckets.length)return '<div class="empty">No role buckets yet.<br><br><button class="primary" data-act="add-bucket">Create your first role bucket</button></div>';
let html=live.map(b=>{b._collapsed=collapsedSet.has(b.id);return bucketHTML(b,sortCands(allCandidates().filter(c=>c.bucketId===b.id&&candMatches(c))));}).join('');
if(F.showArchived){
const arch=buckets.filter(b=>b.archived);
if(arch.length)html+='<div style="margin-top:8px;color:var(--muted);font-size:12px">Archived buckets: '+arch.map(b=>esc(b.title)+' <button class="ghost" data-act="restore-bucket" data-id="'+b.id+'">restore</button> <button class="ghost danger" data-act="delete-bucket" data-id="'+b.id+'">delete</button>').join(' · ')+'</div>';
}
return html;
}
function sidebarHTML(){
if(!sidebarOpen)return '<aside class="hidden"></aside>';
let body='';
if(sidebarTab==='notes'){
const notes=(G('team-notes')||[]).slice().sort((a,b)=>(b.pinned-a.pinned)||(b.at-a.at));
body='<form data-form="note" style="display:flex;gap:6px;margin-bottom:10px"><input id="note-input" placeholder="Share a note with the team..." style="flex:1" required><button class="primary">Post</button></form>'
+(notes.length?notes.map(n=>'<div class="notes-item'+(n.pinned?' pinned':'')+'"><div>'+esc(n.text)+'</div><div class="meta">'+esc(n.byName)+' · '+rel(n.at)+' <button class="ghost" data-act="pin" data-id="'+n.id+'">'+(n.pinned?'unpin':'pin')+'</button></div></div>').join(''):'<div class="empty">No team notes yet.</div>');
}else{
const feed=G('activity:feed')||[];
body=feed.length?feed.map(f=>'<div class="feed-item"><b>'+esc(f.byName)+'</b> '+esc(f.action)+(f.targetLabel?' — '+esc(f.targetLabel):'')+'<div class="meta">'+rel(f.at)+'</div></div>').join(''):'<div class="empty">No activity yet.</div>';
}
return '<aside><div class="tabs"><button data-tab="notes" class="'+(sidebarTab==='notes'?'active':'')+'">Team notes</button><button data-tab="feed" class="'+(sidebarTab==='feed'?'active':'')+'">Activity</button></div>'+body+'</aside>';
}
const collapsedSet=new Set();
/* ---------- modals ---------- */
function closeModal(){$('#modal').innerHTML='';openCand=null;}
function modalShell(inner){$('#modal').innerHTML='<div class="overlay" data-act="overlay"><div class="modal">'+inner+'</div></div>';}
function fld(label,inner){return '<div><label>'+label+'</label>'+inner+'</div>';}
function selOpts(list,cur,labels){return list.map((v,i)=>'<option value="'+esc(v)+'"'+(v===cur?' selected':'')+'>'+esc(labels?labels[i]:v)+'</option>').join('');}
function openIdentityModal(){
modalShell('<h3>Welcome! Who are you?</h3><p style="font-size:12px;color:var(--muted);margin-bottom:12px">Your name is shown on everything you add or edit so the team knows who did what.</p>'
+'<form data-form="identity"><div class="fgrid">'
+fld('Your name *','<input name="name" required style="width:100%">')
+fld('Role / initials (optional)','<input name="role" style="width:100%">')
+'</div><div class="mrow"><button class="primary">Join the board</button></div></form>');
const inp=$('#modal input[name=name]');if(inp)inp.focus();
}
function openBucketModal(id){
const b=id?G('bucket:'+id):{priority:'medium'};
modalShell('<h3>'+(id?'Edit':'New')+' role bucket</h3><form data-form="bucket" data-id="'+(id||'')+'"><div class="fgrid">'
+fld('Title *','<input name="title" required value="'+esc(b.title||'')+'" style="width:100%">')
+fld('Level','<input name="level" value="'+esc(b.level||'')+'" style="width:100%">')
+fld('Location','<input name="location" value="'+esc(b.location||'')+'" style="width:100%">')
+fld('Hiring manager','<input name="hiringManager" value="'+esc(b.hiringManager||'')+'" style="width:100%">')
+fld('Priority','<select name="priority" style="width:100%">'+selOpts(['low','medium','high'],b.priority)+'</select>')
+fld('Target hires','<input name="targetCount" type="number" min="0" value="'+esc(b.targetCount||0)+'" style="width:100%">')
+'<div class="full">'+fld('Description','<textarea name="description" rows="2" style="width:100%">'+esc(b.description||'')+'</textarea>')+'</div>'
+'</div><div class="mrow"><button type="button" data-act="close">Cancel</button><button class="primary">Save</button></div></form>'
+(id&&b.history?'<details><summary>Edit history ('+b.history.length+')</summary>'+histHTML(b.history)+'</details>':''));
}
function histHTML(h){return (h||[]).slice().reverse().map(e=>'<div class="hist-item">'+esc(e.name)+' '+esc(e.action)+(e.field?' '+esc(e.field)+(e.from!==undefined?': "'+esc(e.from??'')+'" → "'+esc(e.to??'')+'"':''):'')+' · '+rel(e.at)+'</div>').join('');}
function tlHTML(c){
const tps=touchpoints(c);
return tps.length?tps.map(t=>'<div class="tl-item"><b>'+esc(t.type)+'</b> ('+esc(t.direction)+') — '+esc(t.outcome)+(t.note?'<div>'+esc(t.note)+'</div>':'')+'<div class="meta">'+esc(t.byName)+' · '+new Date(t.date||t.createdAt).toLocaleDateString()+' · logged '+rel(t.createdAt)+'</div></div>').join(''):'<div class="empty" style="padding:10px">No touchpoints yet.</div>';
}
function openCandModal(id){
const c=G('candidate:'+id);if(!c)return;openCand=id;
const roster=G('team-roster')||{};
modalShell('<h3>'+esc(c.name)+' '+stageBadge(c.stage)+'</h3>'
+'<form data-form="candidate" data-id="'+id+'"><div class="fgrid">'
+fld('Name *','<input name="name" required value="'+esc(c.name)+'" style="width:100%">')
+fld('Bucket','<select name="bucketId" style="width:100%">'+allBuckets().filter(b=>!b.archived).map(b=>'<option value="'+b.id+'"'+(b.id===c.bucketId?' selected':'')+'>'+esc(b.title)+'</option>').join('')+'</select>')
+fld('Current title','<input name="currentTitle" value="'+esc(c.currentTitle)+'" style="width:100%">')
+fld('Company','<input name="currentCompany" value="'+esc(c.currentCompany)+'" style="width:100%">')
+fld('Stage','<select name="stage" style="width:100%">'+selOpts(STAGES,c.stage)+'</select>')
+fld('Owner','<select name="ownerUserId" style="width:100%"><option value="">—</option>'+Object.entries(roster).map(([uid2,u])=>'<option value="'+uid2+'"'+(uid2===c.ownerUserId?' selected':'')+'>'+esc(u.name)+'</option>').join('')+'</select>')
+fld('Email','<input name="email" value="'+esc((c.links||{}).email||'')+'" style="width:100%">')
+fld('Phone','<input name="phone" value="'+esc((c.links||{}).phone||'')+'" style="width:100%">')
+fld('LinkedIn','<input name="linkedin" value="'+esc((c.links||{}).linkedin||'')+'" style="width:100%">')
+fld('Source','<input name="source" value="'+esc(c.source)+'" style="width:100%">')
+fld('Tags (comma-sep)','<input name="tags" value="'+esc((c.tags||[]).join(', '))+'" style="width:100%">')
+fld('Priority','<select name="priority" style="width:100%"><option value="">No</option><option value="1"'+(c.priority?' selected':'')+'>Yes ★</option></select>')
+'<div class="full">'+fld('Summary note','<textarea name="summaryNote" rows="2" style="width:100%">'+esc(c.summaryNote)+'</textarea>')+'</div>'
+'</div><div class="mrow">'
+(c.archived?'<button type="button" data-act="restore-cand" data-id="'+id+'">Restore</button><button type="button" class="danger" data-act="delete-cand" data-id="'+id+'">Delete permanently</button>':'<button type="button" class="danger" data-act="archive-cand" data-id="'+id+'">Archive</button>')
+'<span class="spacer"></span><button type="button" data-act="close">Close</button><button class="primary">Save</button></div></form>'
+'<h3 style="margin-top:16px">Touchpoints</h3>'
+'<form data-form="touchpoint" data-id="'+id+'" style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-end;margin-bottom:8px">'
+fld('Type','<select name="type">'+selOpts(TP_TYPES)+'</select>')
+fld('Direction','<select name="direction">'+selOpts(['outbound','inbound'])+'</select>')
+fld('Date','<input name="date" type="date" value="'+new Date().toISOString().slice(0,10)+'">')
+fld('Outcome','<select name="outcome">'+selOpts(TP_OUTCOMES)+'</select>')
+fld('Note','<input name="note" placeholder="What happened?" style="min-width:160px">')
+'<button class="primary">Log</button></form>'
+'<div id="tp-list">'+tlHTML(c)+'</div>'
+'<details><summary>Full edit history ('+(c.history||[]).length+')</summary><div id="hist-list">'+histHTML(c.history)+'</div></details>'
+'<div class="attr" style="margin-top:8px">Added by '+esc((c.createdBy||{}).name||'?')+' '+rel(c.createdAt)+' · last edited by '+esc((c.lastEditedBy||{}).name||'?')+' '+rel(c.lastEditedAt)+'</div>');
}
function refreshModalDynamic(){
if(!openCand)return;const c=G('candidate:'+openCand);if(!c){closeModal();return;}
const tl=$('#tp-list');if(tl)tl.innerHTML=tlHTML(c);
const hl=$('#hist-list');if(hl)hl.innerHTML=histHTML(c.history);
}
/* ---------- export ---------- */
async function doExport(){
const roster=G('team-roster')||{};
const rows=[['Bucket','Name','Title','Company','Stage','Owner','Email','LinkedIn','Priority','Tags','Last touchpoint','# Touchpoints','Created by','Last edited by','Last edited at']];
for(const c of allCandidates().filter(c=>!c.archived)){
const b=G('bucket:'+c.bucketId)||{};const lt=lastTouchAt(c);
rows.push([b.title||'',c.name,c.currentTitle,c.currentCompany,c.stage,(roster[c.ownerUserId]||{}).name||'',(c.links||{}).email||'',(c.links||{}).linkedin||'',c.priority?'yes':'',(c.tags||[]).join(', '),lt?new Date(lt).toLocaleDateString():'',(c.touchpointIds||[]).length,(c.createdBy||{}).name||'',(c.lastEditedBy||{}).name||'',c.lastEditedAt?new Date(c.lastEditedAt).toLocaleString():'']);
}
try{await vibes.exportData(rows,{filename:'recruitment.xlsx'});toast('Exported');}catch(e){toast('Export failed');}
}
/* ---------- events ---------- */
document.addEventListener('click',async e=>{
const t=e.target;
const clear=t.closest('[data-clear]');
if(clear){const k=clear.dataset.clear;
if(k==='*'){F={...F,q:'',bucket:'',stage:'',owner:'',tag:'',contributor:'',priority:false,followup:false,showArchived:false};}
else F[k]=(typeof F[k]==='boolean')?false:'';
savePrefs();render();return;}
const open=t.closest('[data-open]');
if(open&&!t.closest('button,select,input')){openCandModal(open.dataset.open);return;}
const tab=t.closest('[data-tab]');
if(tab){sidebarTab=tab.dataset.tab;render();return;}
const el=t.closest('[data-act]');if(!el)return;
const act=el.dataset.act,id=el.dataset.id;
try{
if(act==='overlay'&&e.target===el)closeModal();
else if(act==='close')closeModal();
else if(act==='add-bucket')openBucketModal(null);
else if(act==='edit-bucket')openBucketModal(id);
else if(act==='archive-bucket'){await setBucketArchived(id,true);}
else if(act==='restore-bucket'){await setBucketArchived(id,false);}
else if(act==='delete-bucket'){if(confirm('Permanently delete this bucket? This cannot be undone.'))await hardDeleteBucket(id);}
else if(act==='bucket-up'){await moveBucket(id,-1);}
else if(act==='bucket-down'){await moveBucket(id,1);}
else if(act==='collapse'){collapsedSet.has(id)?collapsedSet.delete(id):collapsedSet.add(id);render();}
else if(act==='archive-cand'){await setCandArchived(id,true);closeModal();}
else if(act==='restore-cand'){await setCandArchived(id,false);closeModal();}
else if(act==='delete-cand'){if(confirm('Permanently delete this candidate and all touchpoints? This cannot be undone.')){await hardDeleteCand(id);closeModal();}}
else if(act==='pin'){await togglePin(id);}
else if(act==='export'){await doExport();}
else if(act==='toggle-sidebar'){sidebarOpen=!sidebarOpen;render();}
}catch(err){console.error(err);}
scheduleRender();
});
document.addEventListener('input',e=>{
const f=e.target.dataset.filter;if(!f)return;
if(e.target.type==='checkbox')F[f]=e.target.checked;else F[f]=e.target.value;
savePrefs();
clearTimeout(document._ft);document._ft=setTimeout(render,f==='q'?200:0);
});
document.addEventListener('keydown',async e=>{
if(e.key!=='Enter')return;
const qa=e.target.dataset&&e.target.dataset.quickadd;
if(qa&&e.target.value.trim()){e.preventDefault();const name=e.target.value.trim();e.target.value='';try{await addCandidate(qa,name);}catch(err){e.target.value=name;}scheduleRender();}
});
document.addEventListener('submit',async e=>{
const form=e.target.closest('form[data-form]');if(!form)return;e.preventDefault();
const d=Object.fromEntries(new FormData(form).entries());const kind=form.dataset.form,id=form.dataset.id;
try{
if(kind==='identity'){
me={userId:uid(),name:d.name.trim(),role:(d.role||'').trim()};me.color=colorFor(me.userId);
await vibes.save('me',me);await ensureRoster();closeModal();render();
}else if(kind==='bucket'){
const fields={title:d.title.trim(),description:d.description,level:d.level,location:d.location,hiringManager:d.hiringManager,priority:d.priority,targetCount:+d.targetCount||0};
if(id)await saveBucket(id,fields);else await addBucket(fields);
closeModal();
}else if(kind==='candidate'){
await saveCandidate(id,{name:d.name.trim(),bucketId:d.bucketId,currentTitle:d.currentTitle,currentCompany:d.currentCompany,stage:d.stage,ownerUserId:d.ownerUserId,source:d.source,priority:!!d.priority,summaryNote:d.summaryNote,tags:d.tags.split(',').map(s=>s.trim()).filter(Boolean),links:{email:d.email,phone:d.phone,linkedin:d.linkedin}});
closeModal();toast('Saved');
}else if(kind==='touchpoint'){
await addTouchpoint(id,{type:d.type,direction:d.direction,date:d.date?new Date(d.date+'T12:00:00').getTime():Date.now(),outcome:d.outcome,note:d.note});
form.reset();form.querySelector('[name=date]').value=new Date().toISOString().slice(0,10);
toast('Touchpoint logged');refreshModalDynamic();
}else if(kind==='note'){
const inp=form.querySelector('input');const text=inp.value.trim();if(text){inp.value='';await addNote(text);}
}
}catch(err){console.error(err);}
scheduleRender();
});
/* ---------- roster / presence ---------- */
async function ensureRoster(){
const r={...(G('team-roster')||{})};const cur=r[me.userId];
if(!cur||cur.name!==me.name||cur.role!==me.role){r[me.userId]={name:me.name,role:me.role||'',color:me.color||colorFor(me.userId)};await put('team-roster',r);}
}
async function pollPresence(){try{userCount=await vibes.shared.getUserCount();}catch(e){}scheduleRender();}
/* ---------- startup ---------- */
function whenVibesReady(cb){
if(typeof vibes!=='undefined'&&vibes&&typeof vibes.onReady==='function'){vibes.onReady(cb);return;}
let tries=0;
const iv=setInterval(()=>{
if(typeof vibes!=='undefined'&&vibes&&typeof vibes.onReady==='function'){clearInterval(iv);vibes.onReady(cb);}
else if(++tries>100){clearInterval(iv);console.error('Vibes SDK never loaded');}
},50);
}
whenVibesReady(async()=>{
try{await vibes.shared.join('recruitment-board',{persistent:true});}catch(e){console.error('join failed',e);$('#app').innerHTML='<div class="empty">Could not connect to the shared board. Please reload.</div>';return;}
vibes.shared.onAny(()=>scheduleRender());
try{const p=await vibes.load('prefs');if(p&&typeof p==='object')F={...F,...p};}catch(e){}
try{me=await vibes.load('me');}catch(e){}
render();
if(!me||!me.userId){openIdentityModal();}
else{if(!me.color)me.color=colorFor(me.userId);try{await ensureRoster();}catch(e){}}
pollPresence();setInterval(pollPresence,30000);
});
</script>
</body>
</html>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Agent-Accessible State (vibes.ai.*)
State written with the standard `vibes.save` / `vibes.load` methods is private to each user and is **NOT readable by AI agents**. To share state with an AI agent (via the MCP connector), use the `vibes.ai` sub-namespace:
| Method | Description |
|--------|-------------|
| `await vibes.ai.setState(key, value)` | Write agent-readable state. Key is stored internally as `ai/<key>`. |
| `await vibes.ai.getState(key)` | Read agent-readable state. Returns `null` if key absent. |
| `await vibes.ai.listKeys()` | List all agent-readable keys (without the `ai/` prefix). |
**Important rules:**
- Regular `vibes.save()` / `vibes.load()` is **NOT agent-accessible** — use `vibes.ai.*` for state you want agents to read.
- The widget **owner** must enable agent access in **Manage → Agent tab** before any agent can read or write `vibes.ai.*` state.
- Keys are auto-prefixed to `ai/` internally; you supply just the short key (e.g. `'context'`).
```js
vibes.onReady(async () => {
// Write state an AI agent can later read
await vibes.ai.setState('context', { currentLevel: 3, score: 1500 });
// Read it back (same auto-prefix applies)
const ctx = await vibes.ai.getState('context');
// List all agent-accessible keys for this widget
const keys = await vibes.ai.listKeys(); // e.g. ['context']
});
```
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **External JS libraries are allowed and encouraged** — load them via a `<script src>` tag from a reputable CDN (jsDelivr, unpkg, or cdnjs) with a pinned version, or inline the library source. The only exception: do NOT add a script tag for the Vibes SDK itself — it is auto-injected (see Rule 2).
6. **Keep total code under 100KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/recruitment-dashboard */