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") payload["candidate_id"]=score.get("candidate_id")
if score.get("user_id") and not payload.get("user_id"): if score.get("user_id") and not payload.get("user_id"):
payload["user_id"]=score["user_id"] payload["user_id"]=score["user_id"]
if score.get("job_post_id"): if score.get("job_post_id"):
payload["scored_job_post_id"]=score["job_post_id"] payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload) return await self.attach_application_history(payload)
# List mode: inbox applications + manual/form applications (dedupe by user). # List mode: inbox applications + manual/form applications (dedupe by user).
inbox_payloads=await self.attach_job_posts(rows) 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 = [] 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 (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 === '/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 === '/forms/definitions') data = {}
if (path === '/s3/open') data = { url: `${base}/fixture-resume.pdf` } 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' }) 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) => { 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 })) 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) }, user)
async function clickText(selector, text) { async function clickText(selector, text) {
const clicked = await page.evaluate((selector, text) => { const clicked = await page.evaluate((selector, text) => {
@ -89,7 +102,8 @@ try {
await load() await load()
assert.equal(await page.$eval('.cw-hero h1', (element) => element.textContent), 'Sarah Khan') 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.$$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') assert.equal(writes.length, 0, 'Opening a candidate is read-only')
for (const width of [1536, 1280, 1024, 768, 390, 320]) { for (const width of [1536, 1280, 1024, 768, 390, 320]) {
await page.setViewport({ width, height: 1100 }) await page.setViewport({ width, height: 1100 })
@ -102,6 +116,13 @@ try {
} }
console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)') console.log('ok Profile data, 3-column desktop and mobile layouts (3201536px)')
await page.setViewport({ width: 1536, height: 1100 }) 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 clickText('.cand-page-actions button', 'Favorite')
await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true') await page.waitForFunction(() => document.querySelector('.cand-page-actions button').getAttribute('aria-pressed') === 'true')
await page.click('.cw-rating [role=radio]:nth-child(5)') await page.click('.cw-rating [role=radio]:nth-child(5)')

View File

@ -16,8 +16,9 @@
"test:candidates": "node candidates-table.test.mjs", "test:candidates": "node candidates-table.test.mjs",
"test:profile": "node candidate-profile.test.mjs", "test:profile": "node candidate-profile.test.mjs",
"test:cvbank": "node cvbank.test.mjs", "test:cvbank": "node cvbank.test.mjs",
"test:browse": "node candidate-browse.test.mjs",
"test:mobile": "node mobile.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": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",

View File

@ -39,17 +39,17 @@ export default function AppLayout() {
}, [location.pathname, setNavOpen]) }, [location.pathname, setNavOpen])
return ( 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> <a className="skip-link" href="#main-content">Skip to content</a>
{(!candidateView || navOpen) && <Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
mobileOpen={navOpen} mobileOpen={navOpen}
onToggleCollapse={toggleCollapsed} onToggleCollapse={toggleCollapsed}
badges={badges} badges={badges}
/>} />
<div className="main-wrap"> <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}> <main className="content" id="main-content" ref={contentRef}>
{/* Keyed by pathname: navigating away from a crashed screen resets it. */} {/* Keyed by pathname: navigating away from a crashed screen resets it. */}
<ErrorBoundary key={location.pathname}> <ErrorBoundary key={location.pathname}>
@ -60,14 +60,14 @@ export default function AppLayout() {
</main> </main>
</div> </div>
{!candidateView && <button <button
className="ai-fab" className="ai-fab"
onClick={() => setDockOpen(true)} onClick={() => setDockOpen(true)}
title="AI Recruiter Assistant" title="AI Recruiter Assistant"
aria-label="Open AI Assistant" aria-label="Open AI Assistant"
> >
<Icon name="sparkles" /> <Icon name="sparkles" />
</button>} </button>
<AiDock open={dockOpen} onClose={() => setDockOpen(false)} /> <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 { Avatar, Icon } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { openCandidateProfile } from '../lib/candidateBrowse'
import * as searchApi from '../api/search' import * as searchApi from '../api/search'
export default function GlobalSearch({ inputRef }) { 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.length > 0 && <div className="search-group-label">Candidates</div>}
{candidates.map((c) => ( {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} /> <Avatar name={c.name} />
<div> <div>
<div className="si-title">{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 Dropdown, { DropdownGroup } from '../ui/Dropdown'
import GlobalSearch from './GlobalSearch' import GlobalSearch from './GlobalSearch'
import { BrandGlyph } from '../components/BrandMark'
import { Icon } from '../ui/primitives' import { Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useTheme } from '../theme/ThemeProvider' 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 { theme, toggleTheme } = useTheme()
const { user, signOut } = useAuth() const { user, signOut } = useAuth()
const { toast } = useToast() const { toast } = useToast()
@ -69,10 +68,6 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
return ( return (
<header className="topbar"> <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"> <button className="icon-btn menu-toggle" onClick={onOpenNav} aria-label="Toggle menu">
<Icon name="menu" /> <Icon name="menu" />
</button> </button>
@ -80,7 +75,7 @@ export default function Topbar({ onOpenNav, searchRef, candidateView = false })
<GlobalSearch inputRef={searchRef} /> <GlobalSearch inputRef={searchRef} />
<div className="topbar-actions"> <div className="topbar-actions">
{!candidateView && <button <button
className="icon-btn" className="icon-btn"
onClick={toggleTheme} onClick={toggleTheme}
aria-pressed={theme === 'dark'} 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-sun"><Icon name="sun" /></span>
<span className="icon-moon"><Icon name="moon" /></span> <span className="icon-moon"><Icon name="moon" /></span>
</button>} </button>
<DropdownGroup> <DropdownGroup>
<Dropdown <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], applications: (email) => ['candidates', 'applications', email],
matching: (p = {}) => ['candidates', 'matching', p], matching: (p = {}) => ['candidates', 'matching', p],
matchingDetail: (id) => ['candidates', 'matching', 'detail', id], 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 // 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 — // 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 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 shell over CandidateProfile in `variant="page"` mode: the identity shell
carries only the userId and the live detail query fills everything else. 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' 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() { export default function CandidatePage() {
const { userId } = useParams() const { userId } = useParams()
const navigate = useNavigate() 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 ( return (
<CandidateProfile <CandidateProfile
key={userId} key={userId}
variant="page" variant="page"
candidate={{ id: userId, userId, name: '' }} candidate={{ id: userId, userId, name: '' }}
browse={browse}
onBrowse={goTo}
onClose={() => (window.history.length > 1 ? navigate(-1) : navigate('/candidates'))} 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 * as s3Api from '../api/s3'
import CandidateFormsTab from './CandidateForms' import CandidateFormsTab from './CandidateForms'
import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace' import CandidateWorkspaceOverview, { CandidateWorkspaceHero } from './CandidateWorkspace'
import { CandidateBrowseNav } from './CandidateBrowse'
import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory' import { PreviousApplications, ReappliedBadge, candidateApplicationsOf } from '../components/ReapplicantHistory'
import { fmtDate, fmtTime, toDate } from '../lib/format' import { fmtDate, fmtTime, toDate } from '../lib/format'
import { companies, moneyK, pick } from '../data/seed' import { companies, moneyK, pick } from '../data/seed'
@ -109,18 +110,27 @@ function useProfileWrite({ userId, mutationFn, success, onDone }) {
*/ */
export default function CandidateProfile({ export default function CandidateProfile({
candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch, candidate: c, atsScore = null, recommendation = null, onClose, onAdvance, onToggleFav, onAtsMatch,
variant = 'modal', variant = 'modal', browse = null, onBrowse,
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const { can, user } = useAuth() const { can, user } = useAuth()
const isManager = isHiringManager(user) const isManager = isHiringManager(user)
const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS const visibleTabs = isManager ? ['Forms', 'Notes'] : TABS
const [searchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [tab, setTab] = useState(() => tabFromSearch( const [tab, setTab] = useState(() => tabFromSearch(
variant === 'page' ? searchParams.get('tab') : null, variant === 'page' ? searchParams.get('tab') : null,
visibleTabs, visibleTabs,
isManager ? 'Forms' : 'Overview', 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 tabId = useId()
const [dialog, setDialog] = useState(null) const [dialog, setDialog] = useState(null)
const [stageReason, setStageReason] = useState('') const [stageReason, setStageReason] = useState('')
@ -358,7 +368,7 @@ export default function CandidateProfile({
<Tabs <Tabs
idBase={tabId} idBase={tabId}
value={tab} value={tab}
onChange={setTab} onChange={changeTab}
className={variant === 'page' ? 'tabs' : 'tabs tabs-wrap'} 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 }))} 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)}`}> <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 {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)} rating={rating} ratingPending={setRating.isPending} onRating={(n) => setRating.mutate(n)}
atsScore={atsScore} recommendation={recommendation} 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} 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 ( return (
<div className="cand-page"> <div className="cand-page">
<div className="cand-page-bar"> <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"> <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> </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> </div>
{body} {body}
{dialog && live && <Modal {dialog && live && <Modal

View File

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

View File

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

View File

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

View File

@ -1,219 +1,183 @@
/* Candidate detail is a focused, full-width workspace. Tokens stay scoped so /* Candidate detail is a compact workspace that follows the app theme.
returning to the rest of the ATS preserves the user's selected theme. */ Previous / Next live in the page bar. There is no extra candidate list. */
.candidate-workspace[data-theme="dark"], .candidate-dialog { .candidate-workspace .content { padding: 0 24px 28px; background: var(--bg); }
color-scheme: dark; html[data-theme="dark"] .candidate-workspace .content { background: radial-gradient(ellipse at 50% 0, #0b29304d, transparent 58%), var(--bg); }
--bg: #03171d; .candidate-workspace .cand-page { max-width: 1680px; margin-inline: auto; font-size: 13px; min-width: 0; }
--bg-elev: #071e26; .candidate-workspace .cand-page-bar { gap: 10px; min-height: 44px; margin: 0; padding-block: 8px; }
--bg-sunken: #0c2933; .candidate-workspace .cand-page-crumb { font-size: 12px; display: flex; align-items: center; min-width: 0; white-space: normal; overflow: visible; }
--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-workspace .cand-page-crumb span { margin: 0 8px; } .candidate-workspace .cand-page-crumb span { margin: 0 8px; }
.candidate-workspace .cand-page-actions { width: auto; } .candidate-workspace .cand-page-crumb strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 .cand-page-actions { width: auto; margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.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, .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-secondary:hover:not(:disabled), .candidate-dialog .btn-secondary:hover:not(:disabled) { background: #13333d; border-color: #39606b; } .candidate-workspace .btn-primary, .candidate-dialog .btn-primary { font-weight: 650; }
.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-sm { min-height: 28px; padding: 4px 8px; font-size: 12px; }
.candidate-workspace .btn-primary:hover:not(:disabled), .candidate-dialog .btn-primary:hover:not(:disabled) { background: #dcff9b; } .candidate-workspace .btn svg, .candidate-dialog .btn svg { width: 15px; height: 15px; flex-shrink: 0; }
.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 :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 :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 button:disabled, .candidate-dialog button:disabled { cursor: not-allowed; opacity: .45; }
.candidate-workspace .star-btn.on { color: var(--primary); border-color: #788e49; } .candidate-workspace .star-btn.on { color: var(--primary); border-color: var(--primary-border); }
.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-browse-nav { display: flex; align-items: center; gap: 4px; }
.cw-hero .cw-avatar { width: 78px; height: 78px; font-size: 28px; flex-shrink: 0; } .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-body, .cw-identity { flex: 1; min-width: 0; }
.cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } .cw-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.cw-name { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 8px; } .cw-name { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.cw-name h1 { margin: 0; font-size: 28px; line-height: 1.2; letter-spacing: -.7px; font-weight: 650; overflow-wrap: anywhere; } .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: 9px 22px; color: var(--text-3); font-size: 12px; } .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: 8px; min-width: 0; overflow-wrap: anywhere; } .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 { color: var(--text-3); text-decoration: none; }
.cw-contact a:hover { color: var(--text); } .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 .cw-external, .cw-info a { color: var(--info); text-decoration: underline; text-underline-offset: 3px; }
.cw-contact svg { width: 16px; height: 16px; flex-shrink: 0; } .cw-contact svg { width: 14px; height: 14px; flex-shrink: 0; }
.cw-hero-actions { display: flex; gap: 10px; flex-shrink: 0; } .cw-hero-actions { display: flex; gap: 8px; flex-shrink: 0; }
.cw-facts { display: grid; grid-template-columns: 1.1fr 1fr .9fr 1.2fr .8fr 1.1fr .8fr; margin-top: 22px; } .cw-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 132px), 1fr)); gap: 12px 0; margin-top: 14px; }
.cw-fact { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 16px; border-left: 1px solid var(--border); } .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:first-child { border-left: 0; padding-left: 0; }
.cw-fact:last-child { padding-right: 0; } .cw-fact > svg { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.cw-fact > svg { width: 20px; height: 20px; color: #c3dce4; flex-shrink: 0; } .cw-fact span { display: block; color: var(--text-3); font-size: 11px; line-height: 1.3; margin-bottom: 2px; }
.cw-fact span { display: block; color: var(--text-3); font-size: 12px; line-height: 1.4; margin-bottom: 4px; } .cw-fact strong { font-size: 12px; font-weight: 500; line-height: 1.35; overflow-wrap: anywhere; }
.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 { min-width: 0; max-width: 100%; }
.cw-tabs .tab { min-height: 55px; padding: 12px 20px; margin: 0; font-size: 13px; font-weight: 400; border-bottom-width: 3px; } .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.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-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, .99fr); gap: 16px; align-items: start; }
.cw-column { display: flex; flex-direction: column; gap: 14px; min-width: 0; } .cw-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.65fr) minmax(0, .95fr); gap: 12px; align-items: start; }
.cw-card { min-width: 0; padding: 18px 17px; border: 1px solid var(--border); border-radius: 12px; background: linear-gradient(120deg, #09232c, #061e26 90%); } .cw-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px; } .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 h2 { font-size: 15px; font-weight: 650; letter-spacing: -.2px; line-height: 1.35; margin: 0; } .cw-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.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-card-head h2 { font-size: 13px; font-weight: 650; letter-spacing: -.1px; line-height: 1.3; margin: 0; }
.cw-link svg { width: 15px; height: 15px; } .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-info { display: grid; gap: 15px; margin: 0; } .cw-link svg { width: 14px; height: 14px; }
.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 { display: grid; gap: 10px; margin: 0; }
.cw-info dt { display: flex; align-items: flex-start; gap: 10px; color: var(--text-3); } .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 svg { width: 15px; height: 15px; margin-top: 1px; flex-shrink: 0; } .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-info dd { margin: 0; overflow-wrap: anywhere; }
.cw-skills { display: flex; gap: 8px; flex-wrap: wrap; } .cw-skills { display: flex; gap: 6px; 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-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; } .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 { 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 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: 13px 6px; border-bottom: 1px solid #15343d; } .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: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 td:last-child, .cw-applications th:last-child { padding-right: 0; }
.cw-applications tr:last-child td { border-bottom: 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 td:nth-child(2) { color: var(--text-2); white-space: nowrap; }
.cw-applications strong { display: block; font-size: 13px; font-weight: 550; } .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: 4px; } .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 .badge { font-size: 11px; padding: 3px 7px; }
.cw-applications .is-current { background: linear-gradient(90deg, #12353438, transparent); } .cw-applications .is-current { background: linear-gradient(90deg, var(--primary-soft), 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 { 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: 10px; } .cw-summary p + p { margin-top: 8px; }
.cw-empty { color: var(--text-3); font-size: 13px; line-height: 1.7; margin: 0; } .cw-empty { color: var(--text-3); font-size: 12.5px; line-height: 1.6; 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 { 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: 29px; height: 36px; background: linear-gradient(135deg, #ff7575, #df424d); border-radius: 4px; color: #fff; flex-shrink: 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: 16px; height: 16px; } .cw-document-icon svg { width: 14px; height: 14px; }
.cw-document-icon small { font-size: 7px; line-height: 1.2; margin-top: 2px; } .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 { 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 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-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cw-document-list { display: grid; gap: 9px; } .cw-document-list { display: grid; gap: 8px; }
.candidate-workspace .cw-icon-label { width: 30px; font-size: 0; gap: 0; } .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: 14px; } .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 { align-self: start; }
.cw-bottom-grid .cw-card { padding: 17px; } .cw-rating { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.cw-rating { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.cw-rating > span { font-size: 12px; color: var(--primary); } .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 { 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 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-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: 9px; } .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: 8px; white-space: normal; text-align: left; } .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 #ae4a55; color: #ff7c86; background: #2a172055; } .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: #50232b; } .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: -4px 0 12px; } .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: 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: 12px; margin-bottom: 5px; } .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: 37px; padding: 8px 10px; background: #0c2933; color: var(--text); border: 1px solid var(--border); border-radius: 7px; font-size: 12px; } .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-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 { width: 7px; height: 7px; background: var(--success); border-radius: 50%; flex-shrink: 0; }
.cw-status-dot.is-closed { background: var(--text-3); } .cw-status-dot.is-closed { background: var(--text-3); }
.cw-activity { list-style: none; margin: 0; padding: 0; } .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: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::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: 15px; bottom: 3px; background: #315662; } .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-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.cw-activity strong { font-size: 12px; font-weight: 550; } .cw-activity strong { font-size: 12px; font-weight: 550; }
.cw-activity time { color: var(--text-3); font-size: 11px; white-space: nowrap; } .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-activity small { color: var(--text-3); font-size: 11px; }
.cw-screening { display: flex; align-items: center; gap: 18px; } .cw-screening { display: flex; align-items: center; gap: 14px; }
.cw-match { display: grid; justify-items: center; gap: 6px; flex-shrink: 0; } .cw-match { display: grid; justify-items: center; gap: 4px; flex-shrink: 0; }
.cw-match small { color: var(--text-3); font-size: 12px; } .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 { border: 1px solid var(--border); width: min(620px, calc(100vw - 24px)); }
.candidate-dialog .form-field { margin-top: 16px; } .candidate-dialog .form-field { margin-top: 16px; }
.candidate-dialog .empty-state { padding: 20px; } .candidate-dialog .empty-state { padding: 20px; }
.cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; } .cw-dialog-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-end; }
.cw-muted { color: var(--text-3); } .cw-muted { color: var(--text-3); }
@media (max-width: 1279px) { /* Sidebar is 262px, so these fire at the same content width as a full-bleed 1279/900. */
.candidate-workspace .content { padding-inline: 24px; } @media (max-width: 1560px) {
.candidate-workspace .topbar { padding-inline: 24px; } .candidate-workspace .content { padding-inline: 18px; }
.candidate-brand { min-width: 200px; } .candidate-workspace .topbar { padding-inline: 18px; }
.cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); } .cw-overview { grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr); }
.cw-column-info { grid-column: 1; grid-row: 1; } .cw-column-info { grid-column: 1; grid-row: 1; }
.cw-column-main { grid-column: 2; grid-row: 1 / span 2; } .cw-column-main { grid-column: 2; grid-row: 1 / span 2; }
.cw-column-actions { grid-column: 1; grid-row: 2; } .cw-column-actions { grid-column: 1; grid-row: 2; }
.cw-hero-top { flex-wrap: wrap; } .cw-hero-top { flex-wrap: wrap; }
.cw-facts { grid-template-columns: repeat(4, minmax(0, 1fr)); row-gap: 18px; } .cw-hero-actions { margin-left: auto; }
.cw-fact:nth-child(5) { border-left: 0; padding-left: 0; }
.cw-bottom-grid { grid-template-columns: minmax(0, 1fr); } .cw-bottom-grid { grid-template-columns: minmax(0, 1fr); }
.cw-bottom-grid > .cw-column { display: grid; grid-template-columns: 1fr 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) { @media (max-width: 767px) {
.candidate-workspace .content { padding: 0 16px 28px; } .candidate-workspace .content { padding: 0 12px 20px; }
.candidate-workspace .topbar { padding: 12px 16px; gap: 10px; flex-wrap: wrap; } .candidate-workspace .topbar { padding: 10px 12px; gap: 8px; 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 .topbar-search { order: 4; max-width: none; flex-basis: 100%; } .candidate-workspace .topbar-search { order: 4; max-width: none; flex-basis: 100%; }
.candidate-workspace .topbar-actions { margin-left: 0; } .candidate-workspace .topbar-actions { margin-left: 0; }
.candidate-workspace .topbar-divider, .candidate-workspace .profile-meta, .candidate-workspace .profile-btn .chev { display: none; } .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-bar { gap: 8px; padding-block: 6px; flex-wrap: wrap; }
.candidate-workspace .cand-page-crumb { flex: 1; } .candidate-workspace .cand-page-crumb { flex: 1; min-width: 0; }
.cw-hero { padding: 18px 16px; gap: 14px; flex-wrap: wrap; } .candidate-workspace .cand-page-crumb .cw-crumb-path { display: none; }
.cw-hero .cw-avatar { width: 54px; height: 54px; font-size: 22px; } .candidate-workspace .cand-page-actions { width: 100%; margin-left: 0; justify-content: flex-end; flex-wrap: nowrap; }
.cw-hero-body { display: contents; } .candidate-workspace .cand-page-actions .btn { flex: 0 0 auto; }
.cw-hero-top { flex: 1; min-width: 0; } .candidate-workspace .cand-page-crumb .btn { flex-shrink: 0; }
.cw-identity { flex-basis: 100%; } .cw-browse-nav { gap: 2px; }
.cw-name { gap: 8px; } .cw-browse-arrow { width: 30px; min-height: 30px; padding: 4px; }
.cw-name h1 { font-size: 24px; } .cw-browse-counter { min-width: 44px; font-size: 11px; }
.cw-hero-actions { flex-wrap: wrap; flex-shrink: 1; max-width: 100%; } .cw-hero { padding: 12px; gap: 12px; }
.cw-facts { width: 100%; margin-top: 6px; grid-template-columns: repeat(2, minmax(0, 1fr)); } .cw-hero .cw-avatar { width: 44px; height: 44px; font-size: 16px; }
.cw-fact { padding: 0 12px; gap: 9px; } .cw-name h1 { font-size: 18px; }
.cw-fact:nth-child(odd) { border-left: 0; padding-left: 0; } .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 .tabs { gap: 0; }
.cw-tabs .tab { padding-inline: 16px; } .cw-tabs .tab { padding-inline: 12px; min-height: 40px; }
.cw-overview { display: flex; flex-direction: column; } .cw-action-grid .btn { min-height: 40px; }
.cw-overview > * { width: 100%; } .cw-info > div { grid-template-columns: 120px minmax(0, 1fr); }
.cw-column-actions { order: -1; } .cw-applications { min-width: 360px; }
.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-document { flex-wrap: wrap; } .cw-document { flex-wrap: wrap; }
.cw-document-name { min-width: 100px; } .cw-document-name { min-width: 100px; }
.cw-document-actions { margin-left: auto; } .cw-document-actions { margin-left: auto; }
.cw-bottom-grid > .cw-column { display: flex; } .cw-bottom-grid > .cw-column { display: flex; }
.cw-tab-content { padding: 16px; } .cw-tab-content { padding: 12px; }
.cw-screening { align-items: flex-start; } .cw-screening { align-items: flex-start; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {