UI_CHANGES #18

Merged
ahmed.mujtaba merged 10 commits from UI_CHANGES into main 2026-08-19 14:40:36 +00:00
23 changed files with 1478 additions and 500 deletions

View File

@ -25,6 +25,30 @@ class DuplicateBody(BaseModel):
is_duplicate: bool
class ReadBody(BaseModel):
read: bool = True
class BulkReadBody(BaseModel):
record_ids: list[str]
read: bool = True
class ReadAllBody(BaseModel):
"""The caller's CURRENT list filter, echoed back so the update narrows the same way.
Every field defaults to the same "no filter" value the list endpoint uses, so an
empty body means "the All Applications tab" exactly what GET
/inbox/all-applications returns with no query params.
"""
read: bool = True
search: str | None = None
isread: bool = True
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
assigned: bool | None = None
class TriageOverrideBody(BaseModel):
is_application: bool
@ -145,12 +169,15 @@ async def assign_job_post(
@router.post("/inbox/{record_id}/read")
async def mark_inbox_read(
record_id: str,
payload: ReadBody | None = None,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Flip one row. The body is OPTIONAL and defaults to read=true, so the original
bodyless POST this route shipped with keeps working unchanged."""
try:
service=Email(session=session)
data=await service.mark_read(record_id)
data=await service.mark_read(record_id,payload.read if payload else True)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
@ -158,6 +185,47 @@ async def mark_inbox_read(
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/inbox/read")
async def bulk_mark_inbox_read(
payload: BulkReadBody,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Selected rows -> read/unread. Single segment after /inbox, so it never collides
with the two-segment /inbox/{record_id}/read above."""
try:
service=Email(session=session)
data=await service.set_read_bulk(payload.record_ids,payload.read)
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/inbox/read-all")
async def mark_all_inbox_read(
payload: ReadAllBody,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Every row matching the caller's current list filter -> read/unread."""
try:
service=Email(session=session)
data=await service.set_read_all(
payload.read,
search=payload.search,
isread=payload.isread,
application_status=payload.application_status,
assigned=payload.assigned,
)
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/inbox/{record_id}/read-status")
async def get_inbox_read_status(
record_id: str,

View File

@ -322,6 +322,11 @@ class Inbox_Messages(SQLModel, table=True):
message_cc: str | None = Field(default=None)
message_bcc: str | None = Field(default=None)
message_read: bool = Field(default=False)
# Stamped whenever a human flips read state from the app (single row, bulk, or
# whole view). apply_read_status skips these rows: nothing pushes local state
# back to Outlook, so without the stamp the every-minute sync_read_status sweep
# would silently re-read a mail the recruiter deliberately marked unread.
read_overridden_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
attachment: bool = Field(default=False)
message_reply: str | None = Field(default=None)
file_name: str | None = Field(default=None)
@ -568,29 +573,44 @@ class Inbox_Messages(SQLModel, table=True):
)
@classmethod
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None
def _apply_filters(
cls, statement, search: str | None=None, isread: bool=True,
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None,
):
statement = select(cls).order_by(cls.message_received_time.desc())
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
Works on a Select or an Update both expose .where() which is the whole
point: "mark all read in this view" must narrow on exactly the predicates the
list narrowed on. A scope filter that drifts from the list filter silently
touches rows the user never saw, and there is no undo for that.
"""
if search:
statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if assigned is True:
statement = statement.where(cls.assigned_job_post_id.is_not(None))
elif assigned is False:
statement = statement.where(cls.assigned_job_post_id.is_(None))
if isread==False:
statement = statement.where(cls.message_read==False)
return statement
@classmethod
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None
):
statement = cls._apply_filters(
select(cls).order_by(cls.message_received_time.desc()),
search, isread, application_status, assigned,
)
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement)
return result.scalars().all()
@ -635,17 +655,10 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None):
statement = select(func.count()).select_from(cls)
if search:
statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if assigned is True:
statement = statement.where(cls.assigned_job_post_id.is_not(None))
elif assigned is False:
statement = statement.where(cls.assigned_job_post_id.is_(None))
if isread==False:
statement = statement.where(cls.message_read==False)
statement = cls._apply_filters(
select(func.count()).select_from(cls),
search, isread, application_status, assigned,
)
result = await session.execute(statement)
return result.scalar_one()
@ -659,6 +672,12 @@ class Inbox_Messages(SQLModel, table=True):
every-minute sync_read_status sweep would otherwise revert a mail the user
just opened. Cost of the latch: un-reading a mail in Outlook no longer
propagates here.
Rows with read_overridden_at set are excluded outright. The latch alone is not
enough once the UI can mark UNREAD: a mail that is read in Outlook keeps being
reported isRead=true, so the next sweep would undo the recruiter's click within
the minute. A human decision on this row wins permanently; the only rows
excluded are ones somebody already decided about.
"""
if not changes:
return 0
@ -666,7 +685,9 @@ class Inbox_Messages(SQLModel, table=True):
if not read_ids:
return 0
result=await session.execute(
update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True)
update(cls)
.where(cls.message_id.in_(read_ids),cls.read_overridden_at.is_(None))
.values(message_read=True)
)
await session.commit()
return result.rowcount or 0
@ -698,16 +719,63 @@ class Inbox_Messages(SQLModel, table=True):
return {row for (row,) in result.all() if row}
@classmethod
async def mark_message_read(cls, session: AsyncSession, record_id):
async def mark_message_read(cls, session: AsyncSession, record_id, read: bool=True):
row=await cls.get_inbox_message_by_id(session,record_id)
if not row:
return None
row.message_read=True
row.message_read=bool(read)
row.read_overridden_at=_now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def set_read_bulk(cls, session: AsyncSession, record_ids, read: bool) -> int:
"""Flip read state for an explicit id list in ONE statement. Returns rows matched.
Unparseable ids are dropped rather than raising: a stale row id in a selection
must not sink the other 49 the recruiter ticked. The caller compares `updated`
against `requested` to notice.
No `message_read != read` predicate here the caller wants to know how many of
its ids actually EXIST, which is what rowcount reports without it.
"""
uids=[]
for raw in record_ids or []:
try:
uids.append(uuid.UUID(str(raw)))
except (AttributeError, TypeError, ValueError):
continue
if not uids:
return 0
result=await session.execute(
update(cls).where(cls.id.in_(uids)).values(message_read=bool(read),read_overridden_at=_now())
)
await session.commit()
return result.rowcount or 0
@classmethod
async def set_read_scope(
cls, session: AsyncSession, read: bool, search: str | None=None, isread: bool=True,
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None,
) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED.
The extra `message_read != read` predicate is what makes the count honest: the
recruiter is told "12 marked read", not "1,240 rows touched" on a mailbox that
was already read. It also keeps read_overridden_at off rows nobody decided
anything about, so the Outlook sweep keeps its reach over untouched mail.
"""
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned)
statement=statement.where(cls.message_read!=bool(read))
result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now())
)
await session.commit()
return result.rowcount or 0
@classmethod
async def count_processing(cls, session: AsyncSession):
statement = select(

View File

@ -32,6 +32,10 @@ from datetime import datetime,timezone
logger=logging.getLogger("inbox.match")
triage_logger=logging.getLogger("inbox.triage")
# One statement, one round trip — but an unbounded id list is still a client-supplied
# IN () of arbitrary size, so the batch is capped and the route answers 413.
MAX_BULK_READ_IDS=500
class Email:
def __init__(self,session:AsyncSession,token=None):
@ -349,12 +353,49 @@ class Email:
logger.warning("could not queue ats score for %s: %s",record_id,exc)
return await self.get_inbox_message_by_id(record_id)
async def mark_read(self,record_id):
message=await Inbox_Messages.mark_message_read(self.session,record_id)
async def mark_read(self,record_id,read=True):
message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
if not message:
raise HTTPException(status_code=404,detail="Message not found")
return serialize_message(message)
async def set_read_bulk(self,record_ids,read):
"""Flip read state for a hand-picked selection.
Returns counts, never rows: a 500-id selection would otherwise serialize 500
full messages back at a client that only needs to know it worked.
`updated` < `requested` means some ids no longer exist a stale selection
against a list that moved. That is reported, not raised, because the rows that
DID exist were already committed.
"""
ids=[str(r).strip() for r in (record_ids or []) if str(r or "").strip()]
if not ids:
raise HTTPException(status_code=422,detail="record_ids must contain at least one id")
if len(ids)>MAX_BULK_READ_IDS:
raise HTTPException(status_code=413,
detail=f"At most {MAX_BULK_READ_IDS} ids per request")
updated=await Inbox_Messages.set_read_bulk(self.session,ids,read)
logger.info("bulk read: requested=%s updated=%s read=%s",len(ids),updated,bool(read))
return {"requested":len(ids),"updated":updated,"read":bool(read)}
async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None):
"""Mark every row the SAME filter set would have listed.
The filter arguments are the caller's current view, not a free-form query: the
button says "mark all read in this view" and the WHERE chain is literally the
list's own (Inbox_Messages._apply_filters), so the two cannot drift.
"""
updated=await Inbox_Messages.set_read_scope(
self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned,
)
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s",
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status))
return {"updated":updated,"read":bool(read)}
async def refresh_read_status(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:

View File

@ -42,7 +42,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
platform: str = Field(default="")
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
experience: str = Field(default="")
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied.
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
status: str = Field(default="")
# Free text, not a users FK: a referrer is often someone outside the system
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})

View File

@ -10,7 +10,8 @@
"preview": "vite preview",
"smoke": "node smoke.test.mjs",
"test:token": "node token.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs"
"test:theme": "node theme.test.mjs",
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",

View File

@ -56,9 +56,57 @@ export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } })
}
/** Marks one persisted inbox row read (local DB only). */
export function markRead(recordId) {
return request(`/inbox/${recordId}/read`, { method: 'POST' })
/**
* Flips one persisted inbox row read/unread (local DB only nothing is pushed
* back to Outlook). The body is optional server-side and defaults to read=true.
*/
export function markRead(recordId, read = true) {
return request(`/inbox/${recordId}/read`, { method: 'POST', body: { read } })
}
/**
* Flips a hand-picked selection in one statement. Requires inbox.edit.
*
* Capped at 500 ids server-side (MAX_BULK_READ_IDS in backend/inbox/views.py),
* which answers 413 callers with a longer list chunk it.
*
* Resolves to `{requested, updated, read}`. `updated < requested` means some ids
* no longer exist, not that the call failed: the rows that did exist committed.
*/
export function bulkSetRead(recordIds, read) {
return request('/inbox/read', {
method: 'PATCH',
body: { record_ids: recordIds, read },
})
}
/**
* Flips EVERY row matching a list filter the "mark all in this view" button.
*
* The filter params are deliberately the same ones listApplications takes, and
* the server runs them through the same WHERE builder the list uses
* (Inbox_Messages._apply_filters). Omit them all and the scope is the whole
* mailbox, which is exactly what the All Applications tab shows.
*
* `search` is NOT the Inbox screen's search box: that filters client-side on
* name/position/source, while the server matches subject/from/body. Passing one
* for the other would mark rows the user never saw the screen sends the
* visible ids to bulkSetRead instead whenever its search box is non-empty.
*
* Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast.
*/
export function setReadAll({ read, search, isread, applicationStatus, assigned } = {}) {
return request('/inbox/read-all', {
method: 'PATCH',
body: {
read,
search,
isread,
application_status: applicationStatus,
assigned,
},
})
}
/** Assign (or clear with null) the job post for one application. Requires inbox.edit. */

View File

@ -17,15 +17,15 @@ import { request } from '../lib/apiClient'
*
* The enum has 11 values and the board 7 columns, so this is deliberately
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
* and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and
* and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and
* APPROVED is the pre-HIRED spelling of a hire.
*
* Anything unmapped falls through to Applied rather than vanishing from the
* Anything unmapped falls through to Shortlist rather than vanishing from the
* board a card with no column is a candidate nobody sees.
*/
export const STAGE_FROM_STATUS = {
PENDING: 'Applied',
CLOSED: 'Applied',
PENDING: 'Shortlist',
CLOSED: 'Shortlist',
PROCESS: 'Screening',
ONHOLD: 'Screening',
SCREENING: 'Screening',
@ -41,10 +41,10 @@ export const STAGE_FROM_STATUS = {
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
* never written, so the vocabulary converges on the canonical value as cards get
* moved. Applied writes PENDING because the enum has no APPLIED member.
* moved. Shortlist writes PENDING because the enum has no SHORTLIST member.
*/
export const STATUS_FROM_STAGE = {
Applied: 'PENDING',
Shortlist: 'PENDING',
Screening: 'SCREENING',
Assessment: 'ASSESSMENT',
Interview: 'INTERVIEW',
@ -85,12 +85,12 @@ export function listApplications({ jobId, limit, offset } = {}) {
/**
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
* land in Applied, same as STAGE_FROM_STATUS's card fallback.
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
*/
export function toStageCounts(byStatus) {
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
for (const [status, n] of Object.entries(byStatus || {})) {
const stage = STAGE_FROM_STATUS[status] ?? 'Applied'
const stage = STAGE_FROM_STATUS[status] ?? 'Shortlist'
counts[stage] = (counts[stage] ?? 0) + (n || 0)
}
return counts
@ -179,7 +179,7 @@ export function toBoardCard(row, kind = 'inbox') {
userId: row.user_id ?? null,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist',
status: row.application_status ?? null,
experience: row.experience || null,
aiScore: row.ats_result?.overall_score ?? null,

View File

@ -137,7 +137,7 @@ export function reply(prompt, { candidates, recruiters }) {
<p><b>Pipeline health analysis</b></p>
<ul>
<li>{candidates.length} active candidates across 6 stages</li>
<li>Conversion Applied Interview: ~28%</li>
<li>Conversion Shortlist Interview: ~28%</li>
<li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li>
<li>Offer acceptance trending at 82%</li>
</ul>

View File

@ -33,7 +33,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
const stages = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];
@ -117,7 +117,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
// ---------- Candidates ----------
const openJobs = jobs.filter(j => j.status === 'Open');
const candidates = [];
const stageWeights = ['Applied', 'Applied', 'Applied', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected'];
const stageWeights = ['Shortlist', 'Shortlist', 'Shortlist', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected'];
for (let i = 0; i < 100; i++) {
const name = fullName();
const job = pick(openJobs.length ? openJobs : jobs);

View File

@ -35,7 +35,7 @@ export function useApplications() {
email: row.email ?? null,
jobTitle: row.title ?? null,
jobPostId: row.assigned_job_post_id ?? null,
stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist',
}))
},
})

View File

@ -17,18 +17,22 @@ import Modal from '../ui/Modal'
import { Pagination, useDataTable } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import CandidateProfile, { useJobTitles } from './ScoredCandidateProfile'
import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { useFormState } from '../components/AuthLayout'
import { persist } from '../data/seedQueries'
import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
const EMPTY_FILTERS = { account: '' }
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
const CANDIDATE_ROLE_ID = 8
@ -41,8 +45,8 @@ const CANDIDATE_ROLE_ID = 8
them have.
The consequence is that the ATS columns have no source on this screen see
toCandidateUserView. Open a candidate to get their score, which
ScoredCandidateProfile still reads from the scored endpoint. */
toCandidateUserView. Open a candidate to get their score, which the shared
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
async function fetchCandidates() {
const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
const rows = Array.isArray(res?.data) ? res.data : []
@ -93,6 +97,7 @@ export default function Candidates() {
const qc = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const updateCandidates = useSeedMutation('candidates')
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
@ -121,6 +126,16 @@ export default function Candidates() {
[jobsById],
)
/* Same click-time score fetch Talent Pool uses: GET /pipeline/candidate/score/fetch
only while the profile modal is open, cached per userId. */
const scoreQuery = useQuery({
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
select: pipelineApi.toAtsScore,
enabled: Boolean(profileFor?.userId),
})
const atsScore = scoreQuery.data?.overall_score ?? null
/* The relevance blend (score + matched-skill ratio + recency) went with the
scoring columns none of its three inputs exists on a users row. */
@ -181,7 +196,6 @@ export default function Candidates() {
{ key: 'email', label: 'Email', sortable: true },
{ key: 'isActive', label: 'Account', sortable: true },
{ key: 'applied', label: 'Added', sortable: true },
{ key: '_a', label: 'Actions', align: 'right' },
],
[],
)
@ -208,6 +222,24 @@ export default function Candidates() {
setAtsFor(c)
}
function toggleFav(c) {
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
}
function advance(c) {
const i = STAGE_ORDER.indexOf(c.stage)
if (i === -1 || i >= STAGE_ORDER.length - 1) {
toast(`${c.name} cannot be advanced further`, 'warning')
return
}
const stage = STAGE_ORDER[i + 1]
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p))
toast(`${c.name} moved to ${stage}`, 'success')
}
/* After a manual add, the CV goes through the same persisted scoring pipeline
CV Import and the profile ATS match use (POST /candidate/score): the score
lands in the scored `candidates` table, and re-uploading the same bytes
@ -344,7 +376,11 @@ export default function Candidates() {
</tr>
) : (
t.pageRows.map((c) => (
<tr key={c.id}>
<tr
key={c.id}
style={{ cursor: 'pointer' }}
onClick={() => openProfile(c)}
>
<td>
<div className="user-cell">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
@ -367,11 +403,6 @@ export default function Candidates() {
{c.applied ? c.applied.toLocaleDateString() : '—'}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
</div>
</td>
</tr>
))
)}
@ -394,9 +425,18 @@ export default function Candidates() {
{profileFor && (
<CandidateProfile
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
jobTitle={jobTitleOf(profileFor)}
candidate={{
...(candidates.find((c) => c.id === profileFor.id) ?? profileFor),
initials: initialsOf(profileFor.name),
color: avatarColor(profileFor.name),
stage: profileFor.stage || 'Shortlist',
userId: profileFor.userId || profileFor.id,
}}
atsScore={atsScore}
recommendation={scoreQuery.data?.band ?? null}
onClose={() => setProfileFor(null)}
onAdvance={advance}
onToggleFav={toggleFav}
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
/>
)}

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,10 @@ import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
@ -80,14 +80,6 @@ function htmlToText(value) {
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** Requirement chip lights green when the resume text contains it (client-side). */
function reqInResume(req, resumeText) {
if (!req || !resumeText) return false
const needle = String(req).trim().toLowerCase()
if (!needle) return false
return resumeText.toLowerCase().includes(needle)
}
function mapApplication(row) {
const name = row.name || row.email || 'Unknown'
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
@ -168,129 +160,6 @@ function AssignmentBadge({ item, titleById }) {
return <Badge className="b-amber">No match</Badge>
}
function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(post.id)
}
}}
style={{
cursor: unavailable ? 'not-allowed' : 'pointer',
opacity: unavailable ? 0.55 : 1,
borderColor: selected ? 'var(--primary)' : undefined,
boxShadow: selected ? 'var(--ring)' : undefined,
marginBottom: 8,
alignItems: 'flex-start',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => {
const hit = reqInResume(req, resumeText)
return (
<span
key={req}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
function PickRoleModal({ onClose, onPick }) {
const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
queryFn: async () => {
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
return Array.isArray(res?.data) ? res.data : []
},
})
return (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}
export default function Matching() {
const { toast } = useToast()
const { can } = useAuth()
@ -873,8 +742,7 @@ function MatchingWorkspace({
onClick={onAssign}
>
{selectedPost?.title
? `Assign to ${selectedPost.title}`
: 'Assign'}
? 'Assign' :'Assign'}
</button>
</div>
</div>

View File

@ -28,7 +28,7 @@ import * as pipelineApi from '../api/pipeline'
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
export const KANBAN_STAGES = [
{ name: 'Applied', color: 'var(--stage-1)' },
{ name: 'Shortlist', color: 'var(--stage-1)' },
{ name: 'Screening', color: 'var(--stage-2)' },
{ name: 'Assessment', color: 'var(--stage-3)' },
{ name: 'Interview', color: 'var(--stage-4)' },

View File

@ -45,8 +45,8 @@ const RANGES = [
/* Order matters: "reached" is a running sum from the end of this list back to
the start. REJECTED is deliberately absent see the header note. */
const FUNNEL_ORDER = [
{ key: 'PENDING', label: 'Applied' },
{ key: 'CLOSED', label: 'Applied' },
{ key: 'PENDING', label: 'Shortlist' },
{ key: 'CLOSED', label: 'Shortlist' },
{ key: 'SCREENING', label: 'Screened' },
{ key: 'PROCESS', label: 'Screened' },
{ key: 'ONHOLD', label: 'Screened' },

View File

@ -47,15 +47,15 @@ import { avatarColor, departments, initials as initialsOf } from '../data/seed'
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
const FETCH_LIMIT = 100
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
/**
* Candidate_application_Status (backend/inbox/enums.py) -> the seed stage
* vocabulary every screen renders. CLOSED is the column default, i.e. untriaged,
* so it reads as Applied rather than as an outcome.
* so it reads as Shortlist rather than as an outcome.
*/
const STAGE_FROM_STATUS = {
PENDING: 'Applied', CLOSED: 'Applied', PROCESS: 'Screening',
PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening',
ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected',
}

View File

@ -719,7 +719,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* ================= TABS ================= */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 22px; overflow-x: auto; }
.tab { padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; }
.tab { display: inline-flex; align-items: center; gap: 12px; padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-pane { display: none; animation: fadeUp .25s; }
@ -857,6 +857,46 @@ canvas { width: 100%; max-width: 100%; display: block; }
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
/* Inbox sidebar only: fit the list instead of scrolling sideways.
Username (.ii-name) and subject (.ii-pos) are left alone. */
.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; }
.inbox-queue { overflow-x: hidden; min-width: 0; }
.inbox-queue .inbox-item { min-width: 0; }
.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; }
.inbox-queue .source-chip {
min-width: 0; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.inbox-queue .toolbar-search { min-width: 0; }
.inbox-bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-wrap: nowrap;
min-width: 0;
}
.inbox-bulk-count {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text-3);
}
.inbox-bulk-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.inbox-bulk-actions .btn-sm {
padding: 5px 8px;
font-size: 12px;
white-space: nowrap;
}
/* `--chip` is the source's own brand colour, set inline. It tints the
background and fills the dot, while the label stays on theme text so
11px copy keeps its contrast in both modes. */

View File

@ -47,6 +47,35 @@ export function useTheme() {
return ctx
}
/**
* A counter that increments every time [data-theme] flips. Use it as an effect
* or useMemo dependency.
*
* Almost nothing needs this: a CSS custom property change repaints the whole
* document for free, which is why the app re-themes without any React
* involvement. It exists for the handful of places that CANNOT ride a variable
* anything that bakes a colour into a canvas, a string, or a separate
* document at render time. Those read the palette through getComputedStyle
* exactly once and then hold a stale copy forever, because changing a custom
* property repaints CSS but never re-runs JavaScript.
*
* It watches the ATTRIBUTE rather than subscribing to this provider's state, on
* purpose. initTheme() runs before React mounts and applyTheme() can be called
* from outside the tree, so the attribute is the only source that is always
* current and a component using this hook then needs no provider at all.
* Chart.jsx observes the same attribute directly, for the same reason.
*/
export function useThemeVersion() {
const [version, setVersion] = useState(0)
useEffect(() => {
if (typeof MutationObserver !== 'function') return undefined
const mo = new MutationObserver(() => setVersion((v) => v + 1))
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => mo.disconnect()
}, [])
return version
}
export default function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(
() => document.documentElement.getAttribute('data-theme') || 'light',

View File

@ -21,9 +21,16 @@
Remote images stay blocked until the user asks for them. A tracking pixel in
an applicant email would otherwise tell the sender exactly when a recruiter
opened it.
That CSS isolation has one cost worth stating plainly: custom properties do
not inherit across an iframe boundary, so this is the only component in the
app that cannot re-theme itself for free. Its palette is snapshotted into the
frame's <style> at build time, and useThemeVersion is what reruns that build.
============================================================ */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeVersion } from '../theme/ThemeProvider'
/** Elements that have no business in a rendered email. */
const STRIP_TAGS = [
@ -64,12 +71,20 @@ function sanitize(html) {
return doc.body?.innerHTML || ''
}
/** Inherit the host theme's colours so the email does not glare in dark mode. */
/**
* Inherit the host theme's colours so the email does not glare in dark mode.
*
* Called fresh on every srcDoc build see the themeVersion dependency below.
* Read the attribute for color-scheme rather than sniffing whether --bg happens
* to start with an "f": that guess quietly inverts the frame's form controls and
* scrollbars the moment a palette changes shade.
*/
function frameStyles() {
const css = getComputedStyle(document.documentElement)
const pick = (name, fallback) => (css.getPropertyValue(name) || fallback).trim()
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
return `
:root { color-scheme: ${pick('--bg', '#fff').startsWith('#f') ? 'light' : 'dark'}; }
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
body {
margin: 0;
background: ${pick('--bg-sunken', '#f7f7f8')};
@ -113,8 +128,21 @@ export default function EmailBody({ html, maxHeight }) {
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
const [height, setHeight] = useState(320)
const [blockedImages, setBlockedImages] = useState(0)
const themeVersion = useThemeVersion()
const clean = sanitize(html)
// Both memoised because srcDoc IS the frame's source: handing React a new but
// equal string tears the document down and rebuilds it. Unmemoised, every
// unrelated parent render re-parsed the mail and reloaded the frame.
const clean = useMemo(() => sanitize(html), [html])
const srcDoc = useMemo(
// themeVersion is the entire reason this list exists. frameStyles()
// snapshots the CSS variables into the frame's <style>, and custom
// properties do not cross into an iframe, so without a rebuild the email
// keeps the palette it was born with while the rest of the app flips.
() => buildSrcDoc(clean, allowRemoteImages),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clean, allowRemoteImages, themeVersion],
)
const measure = useCallback(() => {
const frame = ref.current
@ -171,7 +199,7 @@ export default function EmailBody({ html, maxHeight }) {
onLoad={onLoad}
// No allow-scripts. Ever. See the header comment.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={buildSrcDoc(clean, allowRemoteImages)}
srcDoc={srcDoc}
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
/>
</div>

View File

@ -0,0 +1,147 @@
/* ============================================================
Suggested-role picker shared by Job Matching and Recruitment Inbox.
JobCard is the AI/manual radiogroup row. PickRoleModal is the "choose a
different role" search popup. Requirement chips light green when the
resume text contains them (client-side substring, same as Matching).
============================================================ */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from './Modal'
import { Badge, EmptyState, Icon } from './primitives'
import { friendlyAuthError } from '../lib/errors'
import { qk } from '../lib/queryKeys'
import * as jobPostsApi from '../api/jobPosts'
/** Requirement chip lights green when the resume text contains it (client-side). */
export function reqInResume(req, resumeText) {
if (!req || !resumeText) return false
const needle = String(req).trim().toLowerCase()
if (!needle) return false
return resumeText.toLowerCase().includes(needle)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(post.id)
}
}}
style={{
cursor: unavailable ? 'not-allowed' : 'pointer',
opacity: unavailable ? 0.55 : 1,
borderColor: selected ? 'var(--primary)' : undefined,
boxShadow: selected ? 'var(--ring)' : undefined,
marginBottom: 8,
alignItems: 'flex-start',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => {
const hit = reqInResume(req, resumeText)
return (
<span
key={req}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
export function PickRoleModal({ onClose, onPick }) {
const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
queryFn: async () => {
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
return Array.isArray(res?.data) ? res.data : []
},
})
return (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}

View File

@ -27,8 +27,7 @@ export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
className={`tab${active ? ' active' : ''}`}
onClick={() => onChange(key)}
>
{label}
{t.count != null && <span className="tab-count">{t.count}</span>}
{label}{t.count != null && <span className="tab-count">{t.count}</span>}
</button>
)
})}

View File

@ -34,7 +34,7 @@ export function AvatarStack({ names = [], max = 3 }) {
// The 30-entry status -> class map from js/ui.js:73-81, verbatim.
export const STATUS_CLASS = {
Open: 'b-green', Closed: 'b-gray', 'On Hold': 'b-amber', Draft: 'b-blue',
Applied: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo',
Shortlist: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo',
Offer: 'b-teal', Hired: 'b-green', Rejected: 'b-red',
Scheduled: 'b-blue', Completed: 'b-green', Cancelled: 'b-red', 'No Show': 'b-amber',
Sent: 'b-blue', Accepted: 'b-green', Negotiating: 'b-amber', Declined: 'b-red', Expired: 'b-gray',

143
frontend/theme.test.mjs Normal file
View File

@ -0,0 +1,143 @@
/**
* Theme-propagation test.
*
* npm run test:theme
*
* Flipping [data-theme] repaints the entire app for free, because every colour
* in styles.css is a custom property. This test guards the one component that
* cannot ride that: EmailBody renders into an iframe, and custom properties do
* not inherit across a document boundary, so its palette is snapshotted into the
* frame's <style> and has to be rebuilt by hand.
*
* That made it the one place where a theme switch left stale colours on screen
* on /inbox (both tabs) and /matching. The assertions below are what stops it
* regressing: a real MutationObserver, a real attribute flip, and the frame's
* own srcDoc read back afterwards.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
import { JSDOM } from 'jsdom'
// ---------------------------------------------------------------- environment
const dom = new JSDOM('<!doctype html><html data-theme="light"><body><div id="root"></div></body></html>', {
url: 'http://localhost:5173/',
pretendToBeVisual: true,
})
globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Element = dom.window.Element
globalThis.Node = dom.window.Node
globalThis.DOMParser = dom.window.DOMParser
globalThis.getComputedStyle = dom.window.getComputedStyle
globalThis.localStorage = dom.window.localStorage
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
globalThis.cancelAnimationFrame = clearTimeout
globalThis.IS_REACT_ACT_ENVIRONMENT = true
// jsdom ships a real MutationObserver, and this test depends on it — the render
// smoke test stubs it out, which would silently make every assertion here pass.
globalThis.MutationObserver = dom.window.MutationObserver
// ---------------------------------------------------------------- bundle
const outDir = mkdtempSync(join(tmpdir(), 'tf-theme-'))
const outFile = join(outDir, 'entry.mjs')
await esbuild.build({
stdin: {
contents: `
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import EmailBody from './ui/EmailBody'
export async function mount(container, html) {
const root = createRoot(container)
await act(async () => { root.render(<EmailBody html={html} />) })
return root
}
export async function flush() { await act(async () => {}) }
`,
resolveDir: 'src',
sourcefile: 'theme-entry.jsx',
loader: 'jsx',
},
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
loader: { '.js': 'jsx', '.jsx': 'jsx' },
logLevel: 'error',
define: {
'process.env.NODE_ENV': '"development"',
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
},
})
// ---------------------------------------------------------------- run
let failed = 0
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL '} ${label}${ok || !detail ? '' : `\n ${detail}`}`)
if (!ok) failed++
}
/** The palette a real stylesheet would supply, set inline so jsdom resolves it. */
function applyPalette(theme) {
const root = dom.window.document.documentElement
const vars = theme === 'dark'
? { '--bg-sunken': '#0e1d1f', '--text': '#e8f2ef', '--border': '#24403f', '--primary': '#ceff71' }
: { '--bg-sunken': '#f1f7f4', '--text': '#10231f', '--border': '#dbe8e2', '--primary': '#004d43' }
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v)
root.setAttribute('data-theme', theme)
}
const srcDocOf = (container) => container.querySelector('iframe')?.getAttribute('srcdoc') || ''
try {
const mod = await import(pathToFileURL(outFile).href)
applyPalette('light')
const container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
await mod.mount(container, '<p>Hello from a candidate.</p>')
const light = srcDocOf(container)
check('renders an iframe', Boolean(light), 'no iframe in the output')
check('light frame declares color-scheme: light', light.includes('color-scheme: light'))
check('light frame picks up the light background', light.includes('#f1f7f4'), light.slice(0, 200))
check('light frame picks up the light text colour', light.includes('#10231f'))
check('the mail itself still renders', light.includes('Hello from a candidate.'))
// The flip under test. MutationObserver delivers on a microtask, so the
// act() flush below is what lets the re-render land before we read back.
applyPalette('dark')
await mod.flush()
await mod.flush()
const dark = srcDocOf(container)
check('THE REGRESSION: frame rebuilds on theme flip', dark !== light,
'srcDoc is byte-identical after the flip — the email kept the light palette')
check('dark frame declares color-scheme: dark', dark.includes('color-scheme: dark'),
dark.slice(0, 200))
check('dark frame picks up the dark background', dark.includes('#0e1d1f'))
check('dark frame picks up the dark text colour', dark.includes('#e8f2ef'))
check('light colours are gone', !dark.includes('#f1f7f4') && !dark.includes('#10231f'))
check('the mail survives the rebuild', dark.includes('Hello from a candidate.'))
// And back, so this is a toggle rather than a one-way upgrade.
applyPalette('light')
await mod.flush()
await mod.flush()
check('flips back to light', srcDocOf(container).includes('color-scheme: light'))
} finally {
rmSync(outDir, { recursive: true, force: true })
}
console.log(failed ? `\n${failed} theme check(s) failed` : '\nAll theme checks passed')
process.exit(failed ? 1 : 0)