Keep the app sidebar on candidate profiles and add compact Previous/Next browsing.

The profile was forcing its own chrome and a candidate list rail; recruiters now stay in the normal shell and step through the list they opened, with a denser layout that still fits a phone.

Co-authored-by: Cursor <cursoragent@cursor.com>
Edition_Fomrs
Talha Ahmed 2026-09-11 15:32:59 +05:00
parent 74bae5330e
commit 80eb5b3bbc
16 changed files with 406 additions and 206 deletions

View File

@ -1263,8 +1263,8 @@ class CandidateView:
payload["candidate_id"]=score.get("candidate_id")
if score.get("user_id") and not payload.get("user_id"):
payload["user_id"]=score["user_id"]
if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload)
# List mode: inbox applications + manual/form applications (dedupe by user).
inbox_payloads=await self.attach_job_posts(rows)

View File

@ -0,0 +1,42 @@
/**
* Candidate browse queue unique ids, neighbors, profile paths.
*
* node candidate-browse.test.mjs
*/
import assert from 'node:assert/strict'
import { uniqueBrowseEntries, neighborsOf, candidatePath } from './src/lib/candidateBrowse.js'
const rows = [
{ userId: 'a', name: 'Ada', stage: 'Interview', jobTitle: 'Backend' },
{ user_id: 'a', name: 'Ada duplicate application' },
{ userId: '', name: 'Skipped' },
{ userId: 'b', email: 'b@example.com' },
{ userId: 'c', name: 'Chris', job_title: 'Design' },
]
const entries = uniqueBrowseEntries(rows)
assert.deepEqual(entries.map((row) => row.userId), ['a', 'b', 'c'])
assert.equal(entries[0].name, 'Ada')
assert.equal(entries[0].stage, 'Interview')
assert.equal(entries[1].name, 'b@example.com')
assert.equal(entries[2].jobTitle, 'Design')
const mid = neighborsOf(entries, 'b')
assert.equal(mid.index, 1)
assert.equal(mid.total, 3)
assert.equal(mid.prev.userId, 'a')
assert.equal(mid.next.userId, 'c')
const first = neighborsOf(entries, 'a')
assert.equal(first.prev, null)
assert.equal(first.next.userId, 'b')
const missing = neighborsOf(entries, 'z')
assert.equal(missing.index, -1)
assert.equal(missing.prev, null)
assert.equal(missing.next, null)
assert.equal(candidatePath('user/1'), '/candidate/user%2F1')
assert.equal(candidatePath('abc', 'Resume'), '/candidate/abc?tab=Resume')
console.log('All candidate browse checks passed')

View File

@ -57,7 +57,13 @@ page.on('request', async (request) => {
let data = []
if (request.method() === 'OPTIONS') return request.respond({ status: 204, headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' } })
if (path === '/users/me') data = activeUser
if (path === '/candidate/fetch') data = candidate
if (path === '/candidate/fetch') {
const userId = new URL(request.url()).searchParams.get('user_id')
if (!userId) data = []
else if (userId === 'test-candidate-b') data = { ...candidate, user_id: 'test-candidate-b', name: 'Omar Ali' }
else if (userId === 'test-candidate-c') data = { ...candidate, user_id: 'test-candidate-c', name: 'Hina Raza' }
else data = candidate
}
if (path === '/forms/definitions') data = {}
if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` }
if (path === '/fixture-resume.pdf') return request.respond({ status: 200, contentType: 'application/pdf', body: '%PDF-1.4\n%%EOF' })
@ -72,6 +78,13 @@ page.on('request', async (request) => {
})
await page.evaluateOnNewDocument((account) => {
localStorage.setItem('tf-auth', JSON.stringify({ access_token: 'fixture-token', refresh_token: 'fixture-refresh', expires_at: Date.now() + 3600000, data: account }))
sessionStorage.setItem('tf-candidate-browse', JSON.stringify({
entries: [
{ userId: 'test-candidate', name: 'Sarah Khan', stage: 'Shortlist', jobTitle: 'Marketing Manager' },
{ userId: 'test-candidate-b', name: 'Omar Ali', stage: 'Interview', jobTitle: 'Marketing Manager' },
{ userId: 'test-candidate-c', name: 'Hina Raza', stage: 'Screening', jobTitle: 'Content Lead' },
],
}))
}, user)
async function clickText(selector, text) {
const clicked = await page.evaluate((selector, text) => {
@ -89,7 +102,8 @@ try {
await load()
assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan')
assert.equal(await page.$$eval('.cw-applications tbody tr', (rows) => rows.length), 3)
assert.equal(await page.$('.sidebar'), null, 'Detail page must use full width')
assert.ok(await page.$('.sidebar'), 'Candidate page keeps the app sidebar')
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '1 of 3')
assert.equal(writes.length, 0, 'Opening a candidate is read-only')
for (const width of [1536, 1280, 1024, 768, 390, 320]) {
await page.setViewport({ width, height: 1100 })
@ -102,6 +116,13 @@ try {
}
console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)')
await page.setViewport({ width: 1536, height: 1100 })
await page.click('[aria-label="Next candidate"]')
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Omar Ali')
assert.equal(await page.$eval('.cw-browse-counter', (element) => element.textContent.trim()), '2 of 3')
assert.match(page.url(), /\/candidate\/test-candidate-b/)
await page.click('[aria-label="Previous candidate"]')
await page.waitForFunction(() => document.querySelector('.cw-hero h1')?.textContent === 'Sarah Khan')
console.log('ok Previous / next walks the candidate list')
await clickText('.cand-page-actions button', 'Favorite')
await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
await page.click('.cw-rating [role=radio]:nth-child(5)')

View File

@ -16,8 +16,9 @@
"test:candidates": "node candidates-table.test.mjs",
"test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs",
"test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs"
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs && node candidate-browse.test.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",

View File

@ -39,17 +39,17 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen])
return (
<div id="app" className={candidateView ? 'candidate-workspace' : undefined} data-theme={candidateView ? 'dark' : undefined}>
<div id="app" className={candidateView ? 'candidate-workspace' : undefined}>
<a className="skip-link" href="#main-content">Skip to content</a>
{(!candidateView || navOpen) && <Sidebar
<Sidebar
collapsed={collapsed}
mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed}
badges={badges}
/>}
/>
<div className="main-wrap">
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} candidateView={candidateView} />
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
<main className="content" id="main-content" ref={contentRef}>
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
<ErrorBoundary key={location.pathname}>
@ -60,14 +60,14 @@ export default function AppLayout() {
</main>
</div>
{!candidateView && <button
<button
className="ai-fab"
onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant"
aria-label="Open AI Assistant"
>
<Icon name="sparkles" />
</button>}
</button>
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} />

View File

@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query'
import { Avatar, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { openCandidateProfile } from '../lib/candidateBrowse'
import * as searchApi from '../api/search'
export default function GlobalSearch({ inputRef }) {
@ -79,7 +80,7 @@ export default function GlobalSearch({ inputRef }) {
{candidates.length > 0 && <div className="search-group-label">Candidates</div>}
{candidates.map((c) => (
<div key={c.id} className="search-item" onClick={() => go('/candidates', { openCandidate: c.id })}>
<div key={c.id} className="search-item" onClick={() => openCandidateProfile(navigate, c.id, candidates.map((row) => ({ userId: row.id, name: row.name })))}>
<Avatar name={c.name} />
<div>
<div className="si-title">{c.name}</div>

View File

@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch'
import { BrandGlyph } from '../components/BrandMark'
import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider'
@ -29,7 +28,7 @@ async function fetchNotifications() {
}
}
export default function Topbar({ onOpenNav, searchRef, candidateView = false }) {
export default function Topbar({ onOpenNav, searchRef }) {
const { theme, toggleTheme } = useTheme()
const { user, signOut } = useAuth()
const { toast } = useToast()
@ -69,10 +68,6 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
return (
<header className="topbar">
{candidateView && <Link to="/candidates" className="candidate-brand" aria-label="Utopia Brands — Candidates">
<BrandGlyph />
<span><strong>Utopia Brands</strong><small>HR Portal</small></span>
</Link>}
<button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" />
</button>
@ -80,7 +75,7 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
<GlobalSearch inputRef={searchRef} />
<div className="topbar-actions">
{!candidateView && <button
<button
className="icon-btn"
onClick={toggleTheme}
aria-pressed={theme === 'dark'}
@ -89,7 +84,7 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
>
<span className="icon-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span>
</button>}
</button>
<DropdownGroup>
<Dropdown

View File

@ -0,0 +1,64 @@
/* Session-scoped candidate queue so Previous / Next on the profile walks
the same list the recruiter was looking at (Candidates table, Pipeline
board, CV Bank, search), not an arbitrary server page. */
export const BROWSE_KEY = 'tf-candidate-browse'
export function uniqueBrowseEntries(rows = []) {
const entries = []
const seen = new Set()
for (const row of rows) {
if (!row) continue
const userId = String(row.userId ?? row.user_id ?? '').trim()
if (!userId || seen.has(userId)) continue
seen.add(userId)
entries.push({
userId,
name: String(row.name || row.email || 'Candidate'),
stage: row.stage || null,
jobTitle: row.jobTitle || row.job_title || null,
})
}
return entries
}
export function neighborsOf(entries, userId) {
const id = String(userId ?? '')
const index = entries.findIndex((entry) => entry.userId === id)
return {
index,
total: entries.length,
prev: index > 0 ? entries[index - 1] : null,
next: index >= 0 && index < entries.length - 1 ? entries[index + 1] : null,
current: index >= 0 ? entries[index] : null,
entries,
}
}
export function candidatePath(userId, tab) {
const path = `/candidate/${encodeURIComponent(userId)}`
return tab ? `${path}?tab=${encodeURIComponent(tab)}` : path
}
export function rememberCandidateBrowse(rows) {
const entries = uniqueBrowseEntries(rows)
try {
sessionStorage.setItem(BROWSE_KEY, JSON.stringify({ entries }))
} catch { /* private mode / quota — browsing still works for this click */ }
return entries
}
export function readCandidateBrowse() {
try {
const parsed = JSON.parse(sessionStorage.getItem(BROWSE_KEY) || 'null')
return uniqueBrowseEntries(parsed?.entries || [])
} catch {
return []
}
}
export function openCandidateProfile(navigate, userId, rows, { replace = false, tab } = {}) {
if (rows) rememberCandidateBrowse(rows)
if (!userId) return
navigate(candidatePath(userId, tab), { replace })
}

View File

@ -107,6 +107,7 @@ export const qk = {
applications: (email) => ['candidates', 'applications', email],
matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id],
browse: (p = {}) => ['candidates', 'browse', p],
},
// Board rows come from the same endpoint as qk.candidates.list but are cached
// MAPPED (kanban cards, not the raw envelope), so they need their own key —

View File

@ -0,0 +1,31 @@
import { Icon } from '../ui/primitives'
export function CandidateBrowseNav({ browse, onBrowse }) {
if (!browse || browse.index < 0 || browse.total < 1) return null
const { index, total, prev, next } = browse
return (
<div className="cw-browse-nav">
<button
type="button"
className="btn btn-secondary btn-sm cw-browse-arrow"
disabled={!prev}
aria-label="Previous candidate"
title={prev ? `Previous: ${prev.name}` : 'No previous candidate'}
onClick={() => prev && onBrowse(prev.userId)}
>
<Icon name="chevron-left" />
</button>
<span className="cw-browse-counter">{index + 1} of {total}</span>
<button
type="button"
className="btn btn-secondary btn-sm cw-browse-arrow"
disabled={!next}
aria-label="Next candidate"
title={next ? `Next: ${next.name}` : 'No next candidate'}
onClick={() => next && onBrowse(next.userId)}
>
<Icon name="chevron-right" />
</button>
</div>
)
}

View File

@ -4,21 +4,87 @@
need a real page with a real URL (shareable, refresh-safe). This is a thin
shell over CandidateProfile in `variant="page"` mode: the identity shell
carries only the userId and the live detail query fills everything else.
Opened from Candidates, Talent Pool and the Pipeline board. */
Opened from Candidates, Talent Pool and the Pipeline board.
import { useNavigate, useParams } from 'react-router-dom'
Previous / Next walk the session queue written when the recruiter opened
this profile from a list. A direct URL falls back to the first page of
candidates they can already see, so the arrows still do something. */
import { useEffect } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { candidatePath, neighborsOf, readCandidateBrowse, uniqueBrowseEntries } from '../lib/candidateBrowse'
import * as candidatesApi from '../api/candidates'
import * as pipelineApi from '../api/pipeline'
import CandidateProfile from './CandidateProfile'
function useCandidateBrowse(userId) {
const { user } = useAuth()
const manager = isHiringManager(user)
const stored = readCandidateBrowse()
const fallback = useQuery({
queryKey: qk.candidates.browse({ manager }),
queryFn: async () => {
if (manager) {
const res = await candidatesApi.listForManager({ limit: 200, offset: 0 })
const rows = Array.isArray(res?.data) ? res.data : []
return uniqueBrowseEntries(rows.map((row) => ({
...row,
stage: pipelineApi.STAGE_FROM_STATUS[String(row.application_status || '').toUpperCase()] || null,
})))
}
const res = await candidatesApi.list({ limit: 100, offset: 0 })
const rows = Array.isArray(res?.data) ? res.data.map(candidatesApi.toApplicationListView) : []
return uniqueBrowseEntries(rows)
},
enabled: stored.length === 0 && Boolean(userId),
staleTime: 30_000,
})
const entries = stored.length ? stored : (fallback.data ?? [])
return neighborsOf(entries, userId)
}
export default function CandidatePage() {
const { userId } = useParams()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const browse = useCandidateBrowse(userId)
const tab = searchParams.get('tab') || undefined
function goTo(id) {
if (!id || String(id) === String(userId)) return
navigate(candidatePath(id, tab))
}
useEffect(() => {
function onKey(event) {
if (!event.altKey || event.metaKey || event.ctrlKey) return
const tag = event.target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable) return
if (event.key === 'ArrowLeft' && browse.prev) {
event.preventDefault()
goTo(browse.prev.userId)
}
if (event.key === 'ArrowRight' && browse.next) {
event.preventDefault()
goTo(browse.next.userId)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [browse.prev, browse.next, userId, tab])
return (
<CandidateProfile
key={userId}
variant="page"
candidate={{ id: userId, userId, name: '' }}
browse={browse}
onBrowse={goTo}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))}
/>
)

View File

@ -20,6 +20,7 @@ import * as pipelineApi from '../api/pipeline'
import * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms'
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
import { CandidateBrowseNav } from './CandidateBrowse'
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed'
@ -109,18 +110,27 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
*/
export default function CandidateProfile({
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
variant = 'modal',
variant = 'modal', browse = null, onBrowse,
}) {
const { toast } = useToast()
const { can, user } = useAuth()
const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [searchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const [tab, setTab] = useState(() => tabFromSearch(
variant === 'page' ? searchParams.get('tab') : null,
visibleTabs,
isManager ? 'Forms' : 'Overview',
))
function changeTab(next) {
setTab(next)
if (variant !== 'page') return
setSearchParams((prev) => {
const params = new URLSearchParams(prev)
params.set('tab', next)
return params
}, { replace: true })
}
const tabId = useId()
const [dialog, setDialog] = useState(null)
const [stageReason, setStageReason] = useState('')
@ -358,7 +368,7 @@ export default function CandidateProfile({
<Tabs
idBase={tabId}
value={tab}
onChange={setTab}
onChange={changeTab}
className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'}
tabs={visibleTabs.map((t) => ({ key: t, label: t === 'Interview' ? 'Interviews' : t, count: counts && ['Interview', 'Forms', 'Notes'].includes(t) ? counts[t] : undefined }))}
/>
@ -366,7 +376,7 @@ export default function CandidateProfile({
<div className={`tab-pane active${variant === 'page' && tab !== 'Overview' ? ' cw-tab-content' : ''}`} role="tabpanel" id={`${tabId}-panel-${visibleTabs.indexOf(tab)}`} aria-labelledby={`${tabId}-tab-${visibleTabs.indexOf(tab)}`}>
{tab === 'Overview' && (guard || (variant === 'page' ? <CandidateWorkspaceOverview
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={setTab} onAction={openAction}
candidate={live || c} stage={stageLabel} nextStage={nextStage} onTab={changeTab} onAction={openAction}
rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
atsScore={atsScore} recommendation={recommendation}
atsAction={canScoreAts && can('candidates.create') ? <button className="btn btn-secondary btn-sm" disabled={scoreAts.isPending} onClick={() => scoreAts.mutate()}><Icon name="sparkles" />{scoreAts.isPending ? 'Scoring…' : 'Score with ATS'}</button> : null}
@ -642,15 +652,16 @@ export default function CandidateProfile({
return (
<div className="cand-page">
<div className="cand-page-bar">
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<div className="cand-page-crumb">
Candidates <span>/</span> <strong>{live?.name || c.name || '…'}</strong>
<button className="btn btn-secondary btn-sm" onClick={onClose}>
<Icon name="chevron-left" /> Back
</button>
<span className="cw-crumb-path">Candidates <span>/</span></span> <strong>{live?.name || c.name || '…'}</strong>
</div>
<div className="cand-page-actions">
{!isManager && <button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>}
{onBrowse && <CandidateBrowseNav browse={browse} onBrowse={onBrowse} />}
</div>
{!isManager && <div className="cand-page-actions">
<button className={`btn btn-secondary star-btn${favorite ? ' on' : ''}`} aria-pressed={Boolean(favorite)} disabled={!live || setFavorite.isPending || !can('candidates.edit')} onClick={() => setFavorite.mutate(!favorite)}><Icon name="star" />{favorite ? 'Favorited' : 'Favorite'}</button>
</div>}
</div>
{body}
{dialog && live && <Modal

View File

@ -35,6 +35,7 @@ import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantH
import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
import { openCandidateProfile } from '../lib/candidateBrowse'
const EMPTY_FILTERS = { account: '', stage: '', band: '' }
const SEARCH_DEBOUNCE_MS = 300
@ -159,11 +160,6 @@ function HiringManagerCandidates() {
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
if (id) navigate(`/candidate/${id}`, { replace: true })
}, [location.state, navigate])
const listQuery = useQuery({
queryKey: qk.candidates.managerList(),
queryFn: async () => {
@ -194,6 +190,11 @@ function HiringManagerCandidates() {
})
}, [rowsAll, q, jobId])
useEffect(() => {
const id = location.state?.openCandidate
if (id) openCandidateProfile(navigate, id, rows.length ? rows : undefined, { replace: true })
}, [location.state, navigate, rows])
const columns = [
{
key: 'name',
@ -286,7 +287,7 @@ function HiringManagerCandidates() {
? 'No candidates match these filters.'
: 'No candidates are allocated to jobs opened from your requisitions yet.'
}
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
onRowClick={(r) => r.user_id && openCandidateProfile(navigate, r.user_id, rows)}
/>
)}
</div>
@ -382,10 +383,10 @@ function RecruiterCandidates() {
// Real candidates get the full profile PAGE; the modal stays only as the
// fallback for rows without a user account.
const uid = c.userId
if (uid) navigate(`/candidate/${uid}`)
if (uid) openCandidateProfile(navigate, uid, candidates)
else setProfileFor(c)
},
[qc, navigate],
[qc, navigate, candidates],
)
// Deep links from Talent Pool, global search, dashboard
@ -393,8 +394,8 @@ function RecruiterCandidates() {
const st = location.state
if (!st) return
if (st.openAdd) setAdding(true)
if (st.openCandidate) navigate(`/candidate/${st.openCandidate}`, { replace: true })
}, [location.state, navigate])
if (st.openCandidate) openCandidateProfile(navigate, st.openCandidate, candidates.length ? candidates : undefined, { replace: true })
}, [location.state, navigate, candidates])
const rows = useMemo(() => {
const f = filters

View File

@ -36,6 +36,7 @@ import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as s3Api from '../api/s3'
import { openCandidateProfile } from '../lib/candidateBrowse'
import { avatarColor, initials as initialsOf } from '../data/seed'
const SEARCH_DEBOUNCE_MS = 300
@ -225,7 +226,7 @@ export default function CvBank() {
async function view(row) {
if (!row.isStoredCv) {
if (row.userId) navigate(`/candidate/${row.userId}`)
if (row.userId) openCandidateProfile(navigate, row.userId, rows)
else toast('This applicant has no profile to open', 'info')
return
}

View File

@ -27,6 +27,7 @@ import { friendlyAuthError } from '../lib/errors'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { ReappliedBadge } from '../components/ReapplicantHistory'
import { openCandidateProfile } from '../lib/candidateBrowse'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
@ -276,7 +277,7 @@ export default function Pipeline() {
// The profile page keys off users.id, so an application
// with no linked account cannot deep-link.
if (!c.userId) return
navigate(`/candidate/${c.userId}`)
openCandidateProfile(navigate, c.userId, candidates)
}}
>
<div className="k-card-top">

View File

@ -1,219 +1,183 @@
/* Candidate detail is a focused, full-width workspace. Tokens stay scoped so
returning to the rest of the ATS preserves the user's selected theme. */
.candidate-workspace[data-theme="dark"], .candidate-dialog {
color-scheme: dark;
--bg: #03171d;
--bg-elev: #071e26;
--bg-sunken: #0c2933;
--border: #1b404b;
--border-strong: #315764;
--text: #edf7fa;
--text-2: #b6ced7;
--text-3: #9ebbc6;
--primary: #ccfa70;
--primary-600: #dcff98;
--primary-fg: #14210b;
--primary-soft: #ccfa7012;
--primary-border: #ccfa7040;
--accent-ink: #ccfa70;
--success: #25e9a5;
--success-soft: #07543866;
--warning: #ffd16e;
--warning-soft: #614d164d;
--danger: #ff7c86;
--danger-soft: #66222c66;
--info: #82bcff;
--info-soft: #123c6866;
--purple: #b6a6ff;
--purple-soft: #3e326666;
--teal: #25e9a5;
--teal-soft: #07543866;
--avatar-fg: #071720;
--shadow: none;
color: var(--text);
background: var(--bg);
}
.candidate-workspace .main-wrap { width: 100%; margin-left: 0; }
.candidate-workspace .content { padding: 0 42px 40px; background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
.candidate-workspace .topbar { min-height: 67px; height: auto; padding: 12px 42px; gap: 22px; background: #03181e; border-bottom: 1px solid var(--border); }
.candidate-brand { display: flex; align-items: center; gap: 13px; min-width: 230px; color: var(--text); text-decoration: none; }
.candidate-brand .brand-mark { width: 36px; height: 36px; fill: #25e9a5; }
.candidate-brand strong { display: block; font-size: 18px; line-height: 1.25; letter-spacing: -.4px; }
.candidate-brand small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.candidate-workspace .menu-toggle { display: inline-flex; order: -1; width: 32px; }
.candidate-workspace .topbar-search { max-width: 520px; }
.candidate-workspace .topbar-search input { height: 38px; border-radius: 8px; background: #0c2832; font-size: 13px; }
.candidate-workspace .profile-btn .avatar { background: #a19df5; color: #071720; }
.candidate-workspace .sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 60; transform: none; }
.candidate-workspace .cand-page { max-width: 1740px; margin-inline: auto; font-size: 13px; }
.candidate-workspace .cand-page-bar { gap: 16px; min-height: 58px; margin: 0; }
.candidate-workspace .cand-page-crumb { font-size: 12px; }
/* Candidate detail is a compact workspace that follows the app theme.
Previous / Next live in the page bar. There is no extra candidate list. */
.candidate-workspace .content { padding: 0 24px 28px; background: var(--bg); }
html[data-theme="dark"] .candidate-workspace .content { background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
.candidate-workspace .cand-page { max-width: 1680px; margin-inline: auto; font-size: 13px; min-width: 0; }
.candidate-workspace .cand-page-bar { gap: 10px; min-height: 44px; margin: 0; padding-block: 8px; }
.candidate-workspace .cand-page-crumb { font-size: 12px; display: flex; align-items: center; min-width: 0; white-space: normal; overflow: visible; }
.candidate-workspace .cand-page-crumb span { margin: 0 8px; }
.candidate-workspace .cand-page-actions { width: auto; }
.candidate-workspace .btn, .candidate-dialog .btn { min-height: 36px; padding: 7px 12px; border-radius: 7px; gap: 8px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; }
.candidate-workspace .btn-secondary, .candidate-dialog .btn-secondary { border: 1px solid var(--border); color: var(--text); background: linear-gradient(120deg, #0c2730, #071e26); }
.candidate-workspace .btn-secondary:hover:not(:disabled), .candidate-dialog .btn-secondary:hover:not(:disabled) { background: #13333d; border-color: #39606b; }
.candidate-workspace .btn-primary, .candidate-dialog .btn-primary { color: var(--primary-fg); border: 1px solid #c5ed6e; background: linear-gradient(105deg, #d3fd80, #c9f86b); font-weight: 650; }
.candidate-workspace .btn-primary:hover:not(:disabled), .candidate-dialog .btn-primary:hover:not(:disabled) { background: #dcff9b; }
.candidate-workspace .btn-sm { min-height: 30px; padding: 5px 8px; font-size: 12px; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 16px; height: 16px; flex-shrink: 0; }
.candidate-workspace .cand-page-crumb strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.candidate-workspace .cand-page-actions { width: auto; margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.candidate-workspace .btn, .candidate-dialog .btn { min-height: 32px; padding: 5px 10px; border-radius: 7px; gap: 6px; font-size: 12px; font-weight: 500; box-shadow: none; white-space: nowrap; }
.candidate-workspace .btn-primary, .candidate-dialog .btn-primary { font-weight: 650; }
.candidate-workspace .btn-sm { min-height: 28px; padding: 4px 8px; font-size: 12px; }
.candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 15px; height: 15px; flex-shrink: 0; }
.candidate-workspace :where(button, a, input, select, textarea):focus-visible, .candidate-dialog :where(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.candidate-workspace button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: #788e49; }
.cw-hero { display: flex; gap: 24px; padding: 22px 24px 20px; border: 1px solid var(--border); border-radius: 13px; background: linear-gradient(110deg, #09252e, #061d25 70%, #09252c); }
.cw-hero .cw-avatar { width: 78px; height: 78px; font-size: 28px; flex-shrink: 0; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: var(--primary-border); }
.cw-browse-nav { display: flex; align-items: center; gap: 4px; }
.cw-browse-arrow { width: 32px; padding: 5px; flex-shrink: 0; }
.cw-browse-counter { min-width: 52px; text-align: center; font-size: 12px; color: var(--text-2); white-space: nowrap; }
.cw-hero { display: flex; gap: 16px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(110deg, var(--bg-elev), var(--bg-sunken) 70%, var(--bg-elev)); }
.cw-hero .cw-avatar { width: 56px; height: 56px; font-size: 20px; flex-shrink: 0; }
.cw-hero-body, .cw-identity { flex: 1; min-width: 0; }
.cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.cw-name { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; }
.cw-name h1 { margin: 0; font-size: 28px; line-height: 1.2; letter-spacing: -.7px; font-weight: 650; overflow-wrap: anywhere; }
.cw-contact { display: flex; flex-wrap: wrap; gap: 9px 22px; color: var(--text-3); font-size: 12px; }
.cw-contact > * { display: inline-flex; align-items: center; gap: 8px; min-width: 0; overflow-wrap: anywhere; }
.cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.cw-name { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.cw-name h1 { margin: 0; font-size: 22px; line-height: 1.2; letter-spacing: -.4px; font-weight: 650; overflow-wrap: anywhere; }
.cw-contact { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-3); font-size: 12px; }
.cw-contact > * { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.cw-contact a { color: var(--text-3); text-decoration: none; }
.cw-contact a:hover { color: var(--text); }
.cw-contact .cw-external, .cw-info a { color: #9dd3f1; text-decoration: underline; text-underline-offset: 3px; }
.cw-contact svg { width: 16px; height: 16px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 10px; flex-shrink: 0; }
.cw-facts { display: grid; grid-template-columns: 1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr; margin-top: 22px; }
.cw-fact { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 16px; border-left: 1px solid var(--border); }
.cw-contact .cw-external, .cw-info a { color: var(--info); text-decoration: underline; text-underline-offset: 3px; }
.cw-contact svg { width: 14px; height: 14px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 8px; flex-shrink: 0; }
.cw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr)); gap: 12px 0; margin-top: 14px; }
.cw-hero, .cw-overview, .cw-card, .cw-column { min-width: 0; max-width: 100%; }
.cw-fact { display: flex; align-items: center; gap: 10px; min-width: 0; padding: 0 12px; border-left: 1px solid var(--border); }
.cw-fact:first-child { border-left: 0; padding-left: 0; }
.cw-fact:last-child { padding-right: 0; }
.cw-fact > svg { width: 20px; height: 20px; color: #c3dce4; flex-shrink: 0; }
.cw-fact span { display: block; color: var(--text-3); font-size: 12px; line-height: 1.4; margin-bottom: 4px; }
.cw-fact strong { font-size: 13px; font-weight: 500; line-height: 1.4; overflow-wrap: anywhere; }
.cw-tabs .tabs { gap: 10px; margin-bottom: 16px; }
.cw-tabs .tab { min-height: 55px; padding: 12px 20px; margin: 0; font-size: 13px; font-weight: 400; border-bottom-width: 3px; }
.cw-fact > svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.cw-fact span { display: block; color: var(--text-3); font-size: 11px; line-height: 1.3; margin-bottom: 2px; }
.cw-fact strong { font-size: 12px; font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; }
.cw-tabs { min-width: 0; max-width: 100%; }
.cw-tabs .tabs { gap: 4px; margin-bottom: 12px; min-width: 0; max-width: 100%; }
.cw-tabs .tab { min-height: 42px; padding: 8px 14px; margin: 0; font-size: 12.5px; font-weight: 400; border-bottom-width: 2px; }
.cw-tabs .tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.cw-tabs .tab-count { background: #153941; color: #cbdee4; font-size: 11px; min-width: 18px; }
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .99fr); gap: 16px; align-items: start; }
.cw-column { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.cw-card { min-width: 0; padding: 18px 17px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(120deg, #09232c, #061e26 90%); }
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
.cw-card-head h2 { font-size: 15px; font-weight: 650; letter-spacing: -.2px; line-height: 1.35; margin: 0; }
.cw-link { display: inline-flex; align-items: center; gap: 6px; color: #b4ed91; text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
.cw-link svg { width: 15px; height: 15px; }
.cw-info { display: grid; gap: 15px; margin: 0; }
.cw-info > div { display: grid; grid-template-columns: minmax(115px, .9fr) minmax(0, 1.4fr); gap: 12px; line-height: 1.4; font-size: 12px; }
.cw-info dt { display: flex; align-items: flex-start; gap: 10px; color: var(--text-3); }
.cw-info dt svg { width: 15px; height: 15px; margin-top: 1px; flex-shrink: 0; }
.cw-tabs .tab-count { background: var(--bg-sunken); color: var(--text-2); font-size: 11px; min-width: 16px; }
.cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .95fr); gap: 12px; align-items: start; }
.cw-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.cw-card { min-width: 0; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: linear-gradient(120deg, var(--bg-elev), var(--bg-sunken) 90%); }
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.cw-card-head h2 { font-size: 13px; font-weight: 650; letter-spacing: -.1px; line-height: 1.3; margin: 0; }
.cw-link { display: inline-flex; align-items: center; gap: 6px; color: var(--primary); text-decoration: underline; text-underline-offset: 3px; font-size: 12px; white-space: nowrap; }
.cw-link svg { width: 14px; height: 14px; }
.cw-info { display: grid; gap: 10px; margin: 0; }
.cw-info > div { display: grid; grid-template-columns: minmax(108px, .9fr) minmax(0, 1.4fr); gap: 8px; line-height: 1.4; font-size: 12px; }
.cw-info dt { display: flex; align-items: flex-start; gap: 8px; color: var(--text-3); }
.cw-info dt svg { width: 14px; height: 14px; margin-top: 1px; flex-shrink: 0; }
.cw-info dd { margin: 0; overflow-wrap: anywhere; }
.cw-skills { display: flex; gap: 8px; flex-wrap: wrap; }
.cw-skills > span { padding: 6px 10px; border: 1px solid #284b57; border-radius: 12px; background: #102d38; color: #e0edf3; font-size: 12px; max-width: 100%; overflow-wrap: anywhere; }
.cw-table-wrap { overflow: auto; }
.cw-skills { display: flex; gap: 6px; flex-wrap: wrap; }
.cw-skills > span { padding: 4px 8px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); color: var(--text-2); font-size: 11px; max-width: 100%; overflow-wrap: anywhere; }
.cw-table-wrap { overflow: auto; min-width: 0; max-width: 100%; }
.cw-applications { width: 100%; border-collapse: collapse; font-size: 12px; text-align: left; }
.cw-applications th { color: #bad1dc; text-transform: uppercase; letter-spacing: .4px; font-size: 11px; font-weight: 500; border-top: 1px solid #15343d; border-bottom: 1px solid #15343d; padding: 9px 6px; white-space: nowrap; }
.cw-applications td { padding: 13px 6px; border-bottom: 1px solid #15343d; }
.cw-applications th { color: var(--text-3); text-transform: uppercase; letter-spacing: .4px; font-size: 10px; font-weight: 500; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 7px 6px; white-space: nowrap; }
.cw-applications td { padding: 10px 6px; border-bottom: 1px solid var(--border); }
.cw-applications td:first-child, .cw-applications th:first-child { padding-left: 0; }
.cw-applications td:last-child, .cw-applications th:last-child { padding-right: 0; }
.cw-applications tr:last-child td { border-bottom: 0; }
.cw-applications td:nth-child(2) { color: var(--text-2); white-space: nowrap; }
.cw-applications strong { display: block; font-size: 13px; font-weight: 550; }
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-applications strong { display: block; font-size: 12px; font-weight: 550; }
.cw-applications small { display: block; color: var(--text-3); font-size: 11px; margin-top: 3px; }
.cw-applications .badge { font-size: 11px; padding: 3px 7px; }
.cw-applications .is-current { background: linear-gradient(90deg, #12353438, transparent); }
.cw-summary { margin: 0; color: var(--text-2); font-size: 13px; line-height: 1.8; white-space: pre-line; overflow-wrap: anywhere; }
.cw-summary p + p { margin-top: 10px; }
.cw-empty { color: var(--text-3); font-size: 13px; line-height: 1.7; margin: 0; }
.cw-document { display: flex; align-items: center; gap: 11px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, #0d2c36, #0a232b); min-width: 0; }
.cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 29px; height: 36px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; }
.cw-document-icon svg { width: 16px; height: 16px; }
.cw-applications .is-current { background: linear-gradient(90deg, var(--primary-soft), transparent); }
.cw-summary { margin: 0; color: var(--text-2); font-size: 12.5px; line-height: 1.65; white-space: pre-line; overflow-wrap: anywhere; }
.cw-summary p + p { margin-top: 8px; }
.cw-empty { color: var(--text-3); font-size: 12.5px; line-height: 1.6; margin: 0; }
.cw-document { display: flex; align-items: center; gap: 10px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: linear-gradient(100deg, var(--bg-elev), var(--bg-sunken)); min-width: 0; }
.cw-document-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 26px; height: 32px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 0; }
.cw-document-icon svg { width: 14px; height: 14px; }
.cw-document-icon small { font-size: 7px; line-height: 1.2; margin-top: 2px; }
.cw-document-name { flex: 1; min-width: 0; }
.cw-document-name strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 500; }
.cw-document-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 4px; }
.cw-document-name small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; }
.cw-document-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cw-document-list { display: grid; gap: 9px; }
.candidate-workspace .cw-icon-label { width: 30px; font-size: 0; gap: 0; }
.cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 14px; }
.cw-document-list { display: grid; gap: 8px; }
.candidate-workspace .cw-icon-label { width: 28px; font-size: 0; gap: 0; }
.cw-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 12px; }
.cw-bottom-grid > .cw-card { align-self: start; }
.cw-bottom-grid .cw-card { padding: 17px; }
.cw-rating { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cw-rating { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cw-rating > span { font-size: 12px; color: var(--primary); }
.cw-rating .rating-stars .rs svg { width: 18px; height: 18px; }
.cw-rating .rating-stars .rs svg { width: 16px; height: 16px; }
.cw-recruiter { display: flex; align-items: center; gap: 10px; }
.cw-recruiter .avatar { width: 32px; height: 32px; font-size: 12px; }
.cw-recruiter .avatar { width: 28px; height: 28px; font-size: 11px; }
.cw-recruiter strong { display: block; font-size: 12px; font-weight: 500; }
.cw-recruiter small { display: block; color: var(--text-3); font-size: 12px; margin-top: 3px; }
.cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 9px; }
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 8px; white-space: normal; text-align: left; }
.candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid #ae4a55; color: #ff7c86; background: #2a172055; }
.candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: #50232b; }
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -4px 0 12px; }
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 10px; }
.cw-field-label { display: block; color: var(--text-3); font-size: 12px; margin-bottom: 5px; }
.cw-status-grid select, .cw-status-value { width: 100%; min-height: 37px; padding: 8px 10px; background: #0c2933; color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; }
.cw-recruiter small { display: block; color: var(--text-3); font-size: 11px; margin-top: 2px; }
.cw-action-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
.cw-action-grid .btn { font-size: 12px; justify-content: flex-start; padding: 7px 8px; white-space: normal; text-align: left; }
.candidate-workspace .cw-danger, .candidate-dialog .cw-danger { border: 1px solid var(--danger); color: var(--danger); background: var(--danger-soft); }
.candidate-workspace .cw-danger:hover:not(:disabled), .candidate-dialog .cw-danger:hover:not(:disabled) { background: var(--danger); color: var(--danger-fg); }
.cw-active-application { font-size: 12px; color: var(--text-3); margin: -2px 0 10px; }
.cw-status-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); gap: 8px; }
.cw-field-label { display: block; color: var(--text-3); font-size: 11px; margin-bottom: 4px; }
.cw-status-grid select, .cw-status-value { width: 100%; min-height: 34px; padding: 6px 8px; background: var(--bg-sunken); color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; }
.cw-status-value { display: flex; align-items: center; gap: 8px; }
.cw-status-dot { width: 7px; height: 7px; background: var(--success); border-radius: 50%; flex-shrink: 0; }
.cw-status-dot.is-closed { background: var(--text-3); }
.cw-activity { list-style: none; margin: 0; padding: 0; }
.cw-activity li { position: relative; padding: 0 0 23px 24px; }
.cw-activity li { position: relative; padding: 0 0 16px 22px; }
.cw-activity li:last-child { padding-bottom: 0; }
.cw-activity li::before { content: ''; position: absolute; left: 0; top: 4px; width: 10px; height: 10px; background: #59a8ff; border: 2px solid #245788; border-radius: 50%; z-index: 1; }
.cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 15px; bottom: 3px; background: #315662; }
.cw-activity li::before { content: ''; position: absolute; left: 0; top: 4px; width: 9px; height: 9px; background: var(--info); border: 2px solid var(--bg-elev); border-radius: 50%; z-index: 1; }
.cw-activity li:not(:last-child)::after { content: ''; position: absolute; width: 1px; left: 4px; top: 14px; bottom: 2px; background: var(--border); }
.cw-activity-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.cw-activity strong { font-size: 12px; font-weight: 550; }
.cw-activity time { color: var(--text-3); font-size: 11px; white-space: nowrap; }
.cw-activity p { color: var(--text-3); font-size: 12px; line-height: 1.65; margin: 5px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cw-activity p { color: var(--text-3); font-size: 12px; line-height: 1.55; margin: 4px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cw-activity small { color: var(--text-3); font-size: 11px; }
.cw-screening { display: flex; align-items: center; gap: 18px; }
.cw-match { display: grid; justify-items: center; gap: 6px; flex-shrink: 0; }
.cw-screening { display: flex; align-items: center; gap: 14px; }
.cw-match { display: grid; justify-items: center; gap: 4px; flex-shrink: 0; }
.cw-match small { color: var(--text-3); font-size: 12px; }
.cw-tab-content { padding: 22px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 12px; }
.cw-tab-content { padding: 16px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; }
.candidate-dialog { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); }
.candidate-dialog .form-field { margin-top: 16px; }
.candidate-dialog .empty-state { padding: 20px; }
.cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.cw-muted { color: var(--text-3); }
@media (max-width: 1279px) {
.candidate-workspace .content { padding-inline: 24px; }
.candidate-workspace .topbar { padding-inline: 24px; }
.candidate-brand { min-width: 200px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); }
/* Sidebar is 262px, so these fire at the same content width as a full-bleed 1279/900. */
@media (max-width: 1560px) {
.candidate-workspace .content { padding-inline: 18px; }
.candidate-workspace .topbar { padding-inline: 18px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr); }
.cw-column-info { grid-column: 1; grid-row: 1; }
.cw-column-main { grid-column: 2; grid-row: 1 / span 2; }
.cw-column-actions { grid-column: 1; grid-row: 2; }
.cw-hero-top { flex-wrap: wrap; }
.cw-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 18px; }
.cw-fact:nth-child(5) { border-left: 0; padding-left: 0; }
.cw-hero-actions { margin-left: auto; }
.cw-bottom-grid { grid-template-columns: minmax(0, 1fr); }
.cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 1fr; }
.cw-info > div { grid-template-columns: minmax(105px, .9fr) minmax(0, 1.4fr); }
}
@media (max-width: 1100px) {
.cw-overview { display: flex; flex-direction: column; }
.cw-overview > * { width: 100%; min-width: 0; }
.cw-column-actions { order: 0; }
.cw-hero { flex-wrap: wrap; }
.cw-hero-actions { width: 100%; flex-wrap: wrap; }
.cand-page-actions .star-btn { width: 32px; padding: 5px; font-size: 0; gap: 0; }
.cand-page-actions .star-btn svg { width: 15px; height: 15px; }
}
@media (max-width: 767px) {
.candidate-workspace .content { padding: 0 16px 28px; }
.candidate-workspace .topbar { padding: 12px 16px; gap: 10px; flex-wrap: wrap; }
.candidate-brand { min-width: 0; flex: 1; gap: 9px; }
.candidate-brand strong { font-size: 16px; }
.candidate-brand .brand-mark { width: 30px; }
.candidate-workspace .content { padding: 0 12px 20px; }
.candidate-workspace .topbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; }
.candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; }
.candidate-workspace .topbar-actions { margin-left: 0; }
.candidate-workspace .topbar-divider, .candidate-workspace .profile-meta, .candidate-workspace .profile-btn .chev { display: none; }
.candidate-workspace .cand-page-bar { gap: 10px; padding-block: 12px; }
.candidate-workspace .cand-page-crumb { flex: 1; }
.cw-hero { padding: 18px 16px; gap: 14px; flex-wrap: wrap; }
.cw-hero .cw-avatar { width: 54px; height: 54px; font-size: 22px; }
.cw-hero-body { display: contents; }
.cw-hero-top { flex: 1; min-width: 0; }
.cw-identity { flex-basis: 100%; }
.cw-name { gap: 8px; }
.cw-name h1 { font-size: 24px; }
.cw-hero-actions { flex-wrap: wrap; flex-shrink: 1; max-width: 100%; }
.cw-facts { width: 100%; margin-top: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cw-fact { padding: 0 12px; gap: 9px; }
.cw-fact:nth-child(odd) { border-left: 0; padding-left: 0; }
.candidate-workspace .cand-page-bar { gap: 8px; padding-block: 6px; flex-wrap: wrap; }
.candidate-workspace .cand-page-crumb { flex: 1; min-width: 0; }
.candidate-workspace .cand-page-crumb .cw-crumb-path { display: none; }
.candidate-workspace .cand-page-actions { width: 100%; margin-left: 0; justify-content: flex-end; flex-wrap: nowrap; }
.candidate-workspace .cand-page-actions .btn { flex: 0 0 auto; }
.candidate-workspace .cand-page-crumb .btn { flex-shrink: 0; }
.cw-browse-nav { gap: 2px; }
.cw-browse-arrow { width: 30px; min-height: 30px; padding: 4px; }
.cw-browse-counter { min-width: 44px; font-size: 11px; }
.cw-hero { padding: 12px; gap: 12px; }
.cw-hero .cw-avatar { width: 44px; height: 44px; font-size: 16px; }
.cw-name h1 { font-size: 18px; }
.cw-hero-actions .btn { flex: 1 1 auto; justify-content: center; }
.cw-facts { margin-top: 8px; }
.cw-fact { border-left: 0; padding: 8px 0 0; }
.cw-tabs .tabs { gap: 0; }
.cw-tabs .tab { padding-inline: 16px; }
.cw-overview { display: flex; flex-direction: column; }
.cw-overview > * { width: 100%; }
.cw-column-actions { order: -1; }
.cw-action-grid .btn { min-height: 44px; }
.cw-info > div { grid-template-columns: 135px minmax(0, 1fr); font-size: 13px; }
.cw-applications { min-width: 470px; }
.cw-tabs .tab { padding-inline: 12px; min-height: 40px; }
.cw-action-grid .btn { min-height: 40px; }
.cw-info > div { grid-template-columns: 120px minmax(0, 1fr); }
.cw-applications { min-width: 360px; }
.cw-document { flex-wrap: wrap; }
.cw-document-name { min-width: 100px; }
.cw-document-actions { margin-left: auto; }
.cw-bottom-grid > .cw-column { display: flex; }
.cw-tab-content { padding: 16px; }
.cw-tab-content { padding: 12px; }
.cw-screening { align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) {