ui-polish 7: hardening pass — skeletons, ErrorBoundary, auth font fix
- Skeleton loading system: .skeleton shimmer primitives in CSS (theme- aware via --border) plus a SkeletonRows component, adopted at the main-content loading states of Jobs, Candidates, Inbox, Interviews, Offers, Assessments, Tasks, Managers, Notifications. Small card-level loading states keep their EmptyState text. - ErrorBoundary around the route outlet, keyed by pathname: a throw in one lazy screen shows a recoverable error state instead of blanking the whole app; navigating away resets it. - auth.css stops hardcoding the Belleza stack twice; both sites use var(--font-display) so the display face has one definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>pull/29/head
parent
967c1acc5c
commit
066346af80
|
|
@ -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() {
|
|||
<div className="main-wrap">
|
||||
<Topbar onOpenNav={() => setNavOpen((o) => !o)} searchRef={searchRef} />
|
||||
<main className="content" id="main-content" ref={contentRef}>
|
||||
<Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
{/* Keyed by pathname: navigating away from a crashed screen resets it. */}
|
||||
<ErrorBoundary key={location.pathname}>
|
||||
<Suspense fallback={<div className="route-loading"><Spinner label="Loading" /></div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
<EmptyState icon="alert" title="Something went wrong">
|
||||
This screen hit an unexpected error. The rest of the app is fine —
|
||||
try again, or head back to the dashboard.
|
||||
</EmptyState>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button className="btn btn-secondary" onClick={() => this.setState({ error: null })}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<div className="card">
|
||||
{listQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="check-square" title="Loading…">Fetching assessments from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{listQuery.isError && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="users" title="Loading…">Fetching candidates from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{candidatesQuery.isError && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
<div>
|
||||
{applicationsQuery.isPending && (
|
||||
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
)}
|
||||
{applicationsQuery.isError && (
|
||||
<EmptyState icon="inbox" title="Couldn't load applications">
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="calendar" title="Loading…">Fetching interviews from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{listError && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="card">
|
||||
{jobsQuery.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="briefcase" title="Loading…">Fetching requisitions from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{jobsQuery.isError && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<EmptyState icon="managers" title="Loading…">Fetching hiring managers.</EmptyState>
|
||||
<div className="card"><div className="card-body"><SkeletonRows rows={4} /></div></div>
|
||||
)}
|
||||
{managersQuery.isError && (
|
||||
<EmptyState icon="managers" title="Couldn’t load hiring managers">
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="card">
|
||||
{query.isPending && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="bell" title="Loading…">Fetching notifications.</EmptyState>
|
||||
<SkeletonRows rows={5} />
|
||||
</div>
|
||||
)}
|
||||
{query.isError && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div className="card-body">
|
||||
<EmptyState icon="file" title="Loading…">Fetching offers from the server.</EmptyState>
|
||||
<SkeletonRows rows={6} />
|
||||
</div>
|
||||
)}
|
||||
{offersQuery.isError && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
{tasksQuery.isPending ? (
|
||||
<EmptyState icon="check-square" title="Loading…">Fetching tasks from the server.</EmptyState>
|
||||
<SkeletonRows rows={5} />
|
||||
) : tasksQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load tasks">
|
||||
{friendlyAuthError(tasksQuery.error, 'Request failed')}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div role="status" aria-label="Loading">
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<div className="skeleton-row" aria-hidden="true" key={i}>
|
||||
<span className="skeleton skeleton-avatar" />
|
||||
<div className="flex-1">
|
||||
<span className="skeleton skeleton-line" style={{ width: `${52 - (i % 3) * 9}%` }} />
|
||||
<span className="skeleton skeleton-line" style={{ width: `${34 - (i % 3) * 6}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = 'search', title = 'No results found', children }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
|
|
|
|||
Loading…
Reference in New Issue