diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 22d4d3a..13ba098 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -7,6 +7,7 @@ import AiDock from './AiDock' import { ROUTE_BY_PATH } from './routes' import { useBadges, useHotkeys, useNavOpen, useRouteMeta, useSidebarCollapsed } from './useShell' import Icon from '../ui/icons' +import ErrorBoundary from '../components/ErrorBoundary' import Spinner from '../components/Spinner' export default function AppLayout() { @@ -49,9 +50,12 @@ export default function AppLayout() {
setNavOpen((o) => !o)} searchRef={searchRef} />
-
}> - - + {/* Keyed by pathname: navigating away from a crashed screen resets it. */} + + }> + + + diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..226b5b5 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,39 @@ +/* ============================================================ + ErrorBoundary — a throw in any lazy screen used to blank the whole app. + Mounted around the route outlet in AppLayout, keyed by pathname so simply + navigating away resets it. + ============================================================ */ + +import { Component } from 'react' +import { EmptyState } from '../ui/primitives' + +export default class ErrorBoundary extends Component { + state = { error: null } + + static getDerivedStateFromError(error) { + return { error } + } + + componentDidCatch(error, info) { + console.error('Screen crashed:', error, info?.componentStack) + } + + render() { + if (this.state.error) { + return ( +
+ + This screen hit an unexpected error. The rest of the app is fine — + try again, or head back to the dashboard. + +
+ +
+
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/screens/Assessments.jsx b/frontend/src/screens/Assessments.jsx index 0781205..12e161a 100644 --- a/frontend/src/screens/Assessments.jsx +++ b/frontend/src/screens/Assessments.jsx @@ -14,7 +14,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' -import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, ProgressBar, ScoreChip } from '../ui/primitives' +import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, ProgressBar, ScoreChip, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { useFormState } from '../components/AuthLayout' @@ -212,7 +212,7 @@ export default function Assessments() {
{listQuery.isPending && (
- Fetching assessments from the server. +
)} {listQuery.isError && ( diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 5d4f074..caa1d6a 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -16,7 +16,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import { DataTableHead, Pagination, useDataTable } from '../ui/DataTable' import PageHeader from '../ui/PageHeader' -import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' +import { Avatar, Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import CandidateProfile from './CandidateProfile' import { useJobTitles } from './ScoredCandidateProfile' @@ -324,7 +324,7 @@ export default function Candidates() { {candidatesQuery.isPending && (
- Fetching candidates from the server. +
)} {candidatesQuery.isError && ( diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index a506f9f..b550837 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -12,7 +12,7 @@ import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import PageHeader from '../ui/PageHeader' import { Tabs } from '../ui/Tabs' import { Pagination, pageWindow } from '../ui/DataTable' -import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' +import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' @@ -780,7 +780,7 @@ export default function Inbox() {
{applicationsQuery.isPending && ( - Fetching applications from the server. + )} {applicationsQuery.isError && ( diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 0cfe17c..eb86c3c 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -27,7 +27,7 @@ import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' import { Tabs } from '../ui/Tabs' -import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, Stars } from '../ui/primitives' +import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows, Stars } from '../ui/primitives' import { useToast } from '../ui/Toast' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -306,7 +306,7 @@ export default function Interviews() { {listQuery.isPending && (
- Fetching interviews from the server. +
)} {listError && ( diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index eca6816..e4a9420 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -15,7 +15,7 @@ import AiFieldAssist from '../ui/AiFieldAssist' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' -import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' +import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { useFormState } from '../components/AuthLayout' @@ -252,7 +252,7 @@ export default function Jobs() {
{jobsQuery.isPending && (
- Fetching requisitions from the server. +
)} {jobsQuery.isError && ( diff --git a/frontend/src/screens/Managers.jsx b/frontend/src/screens/Managers.jsx index 7f87def..ca13d8c 100644 --- a/frontend/src/screens/Managers.jsx +++ b/frontend/src/screens/Managers.jsx @@ -4,7 +4,7 @@ import { useMutation, useQuery } from '@tanstack/react-query' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' -import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' +import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' @@ -53,7 +53,7 @@ export default function Managers() { /> {managersQuery.isPending && ( - Fetching hiring managers. +
)} {managersQuery.isError && ( diff --git a/frontend/src/screens/Notifications.jsx b/frontend/src/screens/Notifications.jsx index 927be36..b6ad2ee 100644 --- a/frontend/src/screens/Notifications.jsx +++ b/frontend/src/screens/Notifications.jsx @@ -2,7 +2,7 @@ import { useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import PageHeader from '../ui/PageHeader' -import { EmptyState, Icon } from '../ui/primitives' +import { EmptyState, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -68,7 +68,7 @@ export default function Notifications() {
{query.isPending && (
- Fetching notifications. +
)} {query.isError && ( diff --git a/frontend/src/screens/Offers.jsx b/frontend/src/screens/Offers.jsx index f3f4e5e..a1de046 100644 --- a/frontend/src/screens/Offers.jsx +++ b/frontend/src/screens/Offers.jsx @@ -25,7 +25,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' -import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard } from '../ui/primitives' +import { Avatar, Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -283,7 +283,7 @@ export default function Offers() { {offersQuery.isPending && (
- Fetching offers from the server. +
)} {offersQuery.isError && ( diff --git a/frontend/src/screens/Tasks.jsx b/frontend/src/screens/Tasks.jsx index bec4f1b..901b59c 100644 --- a/frontend/src/screens/Tasks.jsx +++ b/frontend/src/screens/Tasks.jsx @@ -18,7 +18,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import PageHeader from '../ui/PageHeader' -import { Badge, EmptyState, FieldError, Icon, PRIORITY_CLASS } from '../ui/primitives' +import { Badge, EmptyState, FieldError, Icon, PRIORITY_CLASS, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { useFormState } from '../components/AuthLayout' @@ -185,7 +185,7 @@ export default function Tasks() {
{tasksQuery.isPending ? ( - Fetching tasks from the server. + ) : tasksQuery.isError ? ( {friendlyAuthError(tasksQuery.error, 'Request failed')} diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index a07d95f..0f550e6 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -50,7 +50,7 @@ } .auth-aside-copy h1 { - font-family: 'Belleza', Georgia, serif; + font-family: var(--font-display); font-size: clamp(32px, 4vw, 44px); line-height: 1.15; font-weight: 400; @@ -115,7 +115,7 @@ } .auth-brand .brand-name { - font-family: 'Belleza', Georgia, serif; + font-family: var(--font-display); font-weight: 400; letter-spacing: 0.2px; } diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 3bdca50..d7c8c5b 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -866,6 +866,24 @@ canvas { width: 100%; max-width: 100%; display: block; } border-radius: 20px; background: var(--bg-sunken); color: var(--text-2); } .tab.active .tab-count { background: var(--primary-soft); color: var(--primary); } +/* Skeleton loading (primitives.jsx SkeletonRows). The sweep uses --border, + which tracks the theme, so no per-theme rules are needed. */ +.skeleton { + position: relative; overflow: hidden; + background: var(--bg-sunken); border-radius: var(--radius-sm); +} +.skeleton::after { + content: ''; position: absolute; inset: 0; transform: translateX(-100%); + background: linear-gradient(90deg, transparent, var(--border), transparent); + animation: skeletonSweep 1.4s infinite; +} +@keyframes skeletonSweep { to { transform: translateX(100%); } } +.skeleton-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; } +.skeleton-row + .skeleton-row { border-top: 1px solid var(--border); } +.skeleton-avatar { width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0; } +.skeleton-line { display: block; height: 11px; } +.skeleton-line + .skeleton-line { margin-top: 7px; } + /* Keyboard users jump straight past the 20+ sidebar links. */ .skip-link { position: absolute; left: -9999px; z-index: 1000; } .skip-link:focus { diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index 4e53ce4..5783dc4 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -76,6 +76,23 @@ export function ProgressBar({ pct, className }) { ) } +/** Shimmer placeholder for a loading table/list — one row per record slot. */ +export function SkeletonRows({ rows = 5 }) { + return ( +
+ {Array.from({ length: rows }, (_, i) => ( + + ))} +
+ ) +} + export function EmptyState({ icon = 'search', title = 'No results found', children }) { return (