Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/components/ds/Avatar';
import { Surface } from '@/components/ds/Surface';
import { StatusBadge } from '@/components/ds/StatusBadge';
/**
* ActivityCard — one event in a feed: who did what, when.
*
* Renders as a row by default (feeds are dense) and as a bordered card when
* `variant="card"`. Either way the anatomy is fixed — actor, sentence, meta,
* trailing slot — so a feed built from mixed event types stays aligned.
*/
export function ActivityCard({
/** Person or system that acted. */
actor,
actorSrc,
/** Icon shown instead of an avatar, for system events. */
icon: Icon,
iconTone = 'soft',
/** What happened — "Screened Sofia Mendez". */
title,
/** Supporting detail. */
description,
timestamp,
status,
/** Right-aligned slot — a score, a button. */
trailing,
variant = 'row',
onClick,
className,
}) {
const iconTones = {
soft: 'bg-krow-blue-tint text-krow-blue',
brand: 'bg-krow-blue text-white',
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
destructive: 'bg-destructive-muted text-destructive',
neutral: 'bg-surface-sunken text-ink-3',
};
const body = (
<>
{Icon ? (
<span className={cn('grid place-items-center w-9 h-9 rounded-xl shrink-0', iconTones[iconTone])}>
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
) : (
<Avatar name={actor} src={actorSrc} size="default" />
)}
<div className="flex-1 min-w-0">
<p className="text-body-sm text-ink-1">
{actor && <span className="font-semibold">{actor}</span>}
{actor && title && ' '}
{title && <span className={actor ? 'text-ink-2' : 'font-medium'}>{title}</span>}
</p>
{description && <p className="text-caption text-ink-3 mt-0.5 truncate-2">{description}</p>}
{timestamp && <p className="text-caption text-ink-4 mt-1">{timestamp}</p>}
</div>
{(status || trailing) && (
<div className="flex items-center gap-2 shrink-0">
{status && <StatusBadge status={status} size="sm" />}
{trailing}
</div>
)}
</>
);
if (variant === 'card') {
return (
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
variant="solid"
radius="lg"
padding="sm"
className={cn('flex items-start gap-3 w-full text-left', className)}
>
{body}
</Surface>
);
}
const Comp = onClick ? 'button' : 'div';
return (
<Comp
onClick={onClick}
className={cn(
'flex items-start gap-3 w-full text-left rounded-xl p-2.5 transition-colors duration-fast',
onClick && 'hover:bg-white/60 cursor-pointer focus-ring',
className
)}
>
{body}
</Comp>
);
}

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva('flex items-start gap-3 rounded-xl border p-4 animate-slide-down', {
variants: {
tone: {
info: 'bg-info-muted border-krow-blue/20 text-ink-2',
success: 'bg-success-muted border-success/20 text-ink-2',
warning: 'bg-warning-muted border-warning/25 text-ink-2',
destructive: 'bg-destructive-muted border-destructive/20 text-ink-2',
neutral: 'bg-surface-subtle border-border text-ink-2',
},
},
defaultVariants: { tone: 'info' },
});
const ICONS = {
info: Info,
success: CheckCircle2,
warning: TriangleAlert,
destructive: AlertCircle,
neutral: Info,
};
const ICON_COLORS = {
info: 'text-krow-blue',
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
neutral: 'text-ink-4',
};
/**
* Alert — an inline, persistent message about the state of the page.
*
* For transient confirmation of something the user just did, use `toast`
* instead: an Alert that appears and disappears is easy to miss, and a toast
* that persists is noise.
*/
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
const Icon = icon || ICONS[tone];
return (
<div role="alert" className={cn(alertVariants({ tone }), className)} {...props}>
{Icon && <Icon className={cn('w-4.5 h-4.5 shrink-0 mt-0.5', ICON_COLORS[tone])} aria-hidden="true" />}
<div className="flex-1 min-w-0">
{title && <p className="text-body-sm font-semibold text-ink-1">{title}</p>}
{children && (
<div className={cn('text-body-sm text-ink-2 leading-relaxed', title && 'mt-0.5')}>
{children}
</div>
)}
{action && <div className="mt-3">{action}</div>}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="grid place-items-center w-6 h-6 rounded-md text-ink-4 hover:text-ink-1 hover:bg-black/5 transition-colors shrink-0 focus-ring-inset"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const SIZES = {
xs: 'w-6 h-6 text-[9px]',
sm: 'w-7 h-7 text-[10px]',
default: 'w-9 h-9 text-[12px]',
lg: 'w-11 h-11 text-body-sm',
xl: 'w-16 h-16 text-title',
'2xl': 'w-20 h-20 text-title-lg',
};
const avatarVariants = cva(
'relative inline-grid place-items-center shrink-0 overflow-hidden font-semibold select-none',
{
variants: {
shape: { circle: 'rounded-full', rounded: 'rounded-xl' },
tone: {
brand: 'bg-krow-blue text-white',
navy: 'bg-krow-navy text-white',
soft: 'bg-krow-blue-tint text-krow-blue',
neutral: 'bg-surface-sunken text-ink-3',
},
},
defaultVariants: { shape: 'circle', tone: 'brand' },
}
);
/** First letters of the first two words — "Chef Antoine Dubois" → "CA". */
export function initialsFrom(name = '') {
const words = String(name).trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
return words.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
}
/**
* Avatar — a photo when one exists, initials otherwise.
*
* The app shows people constantly and most have no photo, so initials are a
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
export const Avatar = React.forwardRef(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
// Reset when the subject changes, so a new src gets its own chance to load.
React.useEffect(() => setFailed(false), [src]);
return (
<span ref={ref} className={cn('relative inline-block shrink-0', className)} {...props}>
<span
className={cn(avatarVariants({ shape, tone }), SIZES[size] || SIZES.default)}
aria-hidden={showImage ? 'true' : undefined}
>
{showImage ? (
<img
src={src}
alt={name ? `${name}` : ''}
onError={() => setFailed(true)}
className="w-full h-full object-cover"
/>
) : (
<span aria-label={name || undefined}>{initialsFrom(name)}</span>
)}
</span>
{status && (
<span
className={cn(
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
size === 'xs' || size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5',
status === 'online' && 'bg-success',
status === 'busy' && 'bg-warning',
status === 'offline' && 'bg-ink-4'
)}
aria-label={status}
/>
)}
</span>
);
}
);
Avatar.displayName = 'Avatar';
/**
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
* people in a tight row (applicants on a position, endorsers on a skill).
*/
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;
return (
<div className={cn('flex items-center', className)}>
{visible.map((person, i) => (
<Avatar
key={person.id ?? person.name ?? i}
name={person.name}
src={person.src}
size={size}
className={cn('ring-2 ring-white', i > 0 && '-ml-2')}
/>
))}
{overflow > 0 && (
<span
className={cn(
'grid place-items-center rounded-full bg-surface-sunken text-ink-3 font-semibold ring-2 ring-white -ml-2',
SIZES[size] || SIZES.default
)}
>
+{overflow}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
import * as React from 'react';
import { ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { SectionHeader } from '@/components/ds/SectionHeader';
import { EmptyState } from '@/components/ds/EmptyState';
import { SkeletonChart } from '@/components/ds/Skeleton';
import { BarChart3 } from 'lucide-react';
/**
* The chart palette. Recharts needs literal colours, not Tailwind classes, so
* the brand values are mirrored here — this array is the only place a chart
* should get a colour from.
*/
export const CHART_COLORS = ['#0A39DF', '#F9E547', '#333F48', '#F8E08E', '#D1E0D7', '#082EB4'];
export const CHART_TONES = {
brand: '#0A39DF',
brandDark: '#082EB4',
accent: '#F9E547',
accentPale: '#F8E08E',
navy: '#333F48',
mint: '#D1E0D7',
grid: 'hsl(150 8% 87%)',
axis: 'hsl(220 9% 64%)',
};
/** Shared axis props so every chart's ticks match. */
export const AXIS_PROPS = {
stroke: CHART_TONES.axis,
tick: { fontSize: 11, fill: CHART_TONES.axis },
tickLine: false,
axisLine: false,
};
/**
* A tooltip matching the design system's surfaces. Recharts' default is a plain
* white box that ignores the token set.
*/
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2 shadow-lg">
{label != null && (
<p className="text-caption font-semibold text-ink-1 mb-1">
{labelFormatter ? labelFormatter(label) : label}
</p>
)}
<div className="space-y-0.5">
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-caption">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: entry.color || entry.fill }}
aria-hidden="true"
/>
<span className="text-ink-3">{entry.name}</span>
<span className="ml-auto font-semibold text-ink-1 tabular-nums">
{valueFormatter ? valueFormatter(entry.value) : entry.value}
</span>
</div>
))}
</div>
</div>
);
}
/**
* A legend that reads as part of the card rather than as chart furniture.
* Items: `{ label, color, value? }`.
*/
export function ChartLegend({ items = [], className }) {
return (
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
{items.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
<span className="text-caption text-ink-3">{item.label}</span>
{item.value != null && (
<span className="text-caption font-semibold text-ink-1 tabular-nums">{item.value}</span>
)}
</div>
))}
</div>
);
}
/**
* ChartContainer — the card a chart lives in.
*
* Owns the title row, the fixed plot height, the loading skeleton, the empty
* state, and an optional headline figure. Charts inside it are pure Recharts
* children, so swapping a bar chart for a line chart changes nothing else.
*/
export function ChartContainer({
title,
subtitle,
icon,
/** A big number in the top-right — an average, a total. */
headline,
headlineLabel,
/** Right-aligned controls, e.g. a SegmentedToggle for the range. */
actions,
legend,
height = 260,
loading = false,
isEmpty = false,
emptyMessage = 'No data for this period',
children,
/** Skips ResponsiveContainer for children that are not Recharts charts. */
raw = false,
footer,
className,
}) {
if (loading) return <SkeletonChart className={className} height={height} />;
return (
<Surface radius="xl" padding="lg" className={cn('flex flex-col', className)}>
{(title || headline || actions) && (
<div className="flex items-start justify-between gap-4 mb-1">
<div className="min-w-0 flex-1">
{title && (
<SectionHeader
title={title}
subtitle={subtitle}
icon={icon}
className={cn(subtitle ? 'mb-0' : 'mb-0')}
/>
)}
</div>
<div className="flex items-start gap-3 shrink-0">
{actions}
{headline != null && (
<div className="text-right">
<div className="text-title-lg font-heading font-bold text-krow-blue tabular-nums leading-none">
{headline}
</div>
{headlineLabel && (
<div className="text-[10px] uppercase tracking-wide text-ink-4 mt-1">
{headlineLabel}
</div>
)}
</div>
)}
</div>
</div>
)}
{legend && <ChartLegend items={legend} className="mb-4 mt-3" />}
<div className={cn('flex-1 min-w-0', !legend && 'mt-4')} style={{ minHeight: height }}>
{isEmpty ? (
<EmptyState icon={BarChart3} variant="filtered" title="Nothing to chart" description={emptyMessage} size="sm" />
) : raw ? (
children
) : (
<ResponsiveContainer width="100%" height={height}>
{children}
</ResponsiveContainer>
)}
</div>
{footer && <div className="mt-4 pt-4 border-t border-border">{footer}</div>}
</Surface>
);
}

View File

@@ -0,0 +1,309 @@
import * as React from 'react';
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { EmptyState } from '@/components/ds/EmptyState';
import { SkeletonTable } from '@/components/ds/Skeleton';
import { LoadingOverlay } from '@/components/ds/LoadingState';
import { Pagination } from '@/components/ds/Pagination';
import { Checkbox } from '@/components/ui/checkbox';
/**
* DataTable — the app's one table.
*
* Columns are declared as data:
*
* { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? }
*
* Sorting, selection and pagination are handled internally by default, but each
* can be lifted by passing the corresponding value + handler — so the same
* component serves a simple static list and a server-paginated view.
*
* `hideBelow: 'md'` drops a column on small screens, which is what keeps the
* table usable on mobile without a horizontal scroll for every view.
*/
export function DataTable({
columns = [],
rows = [],
/** Stable row identity. Falls back to `row.id`, then the index. */
getRowId = (row, i) => row?.id ?? i,
loading = false,
/** Dims existing rows instead of replacing them — for refetches. */
refreshing = false,
/* Sorting — omit `sort` to let the table own it */
sort: controlledSort,
onSortChange,
defaultSort,
/* Selection — pass `selectedIds` to enable */
selectedIds,
onSelectionChange,
/* Pagination — pass `pageSize` to enable */
pageSize: initialPageSize,
page: controlledPage,
onPageChange,
totalItems,
pageSizeOptions,
onRowClick,
emptyState,
/** True when filters are active, so the empty copy can say so. */
isFiltered = false,
onClearFilters,
caption,
className,
rowClassName,
stickyHeader = false,
}) {
/* ── Sorting ─────────────────────────────────────────────────────────── */
const [internalSort, setInternalSort] = React.useState(defaultSort ?? null);
const sort = controlledSort !== undefined ? controlledSort : internalSort;
const setSort = (next) => {
if (onSortChange) onSortChange(next);
if (controlledSort === undefined) setInternalSort(next);
};
const toggleSort = (column) => {
if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' });
if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' });
// Third click clears sorting, so the original order is reachable.
return setSort(null);
};
const valueFor = (column, row) => {
if (column.sortValue) return column.sortValue(row);
if (column.accessor) return column.accessor(row);
return row?.[column.key];
};
const sortedRows = React.useMemo(() => {
// A controlled `sort` means the parent already ordered the rows.
if (!sort || controlledSort !== undefined) return rows;
const column = columns.find((c) => c.key === sort.key);
if (!column) return rows;
const factor = sort.direction === 'desc' ? -1 : 1;
return [...rows].sort((a, b) => {
const av = valueFor(column, a);
const bv = valueFor(column, b);
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor;
return String(av).localeCompare(String(bv)) * factor;
});
}, [rows, sort, columns, controlledSort]);
/* ── Pagination ──────────────────────────────────────────────────────── */
const paginated = initialPageSize != null;
const [internalPage, setInternalPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25);
const page = controlledPage !== undefined ? controlledPage : internalPage;
const setPage = (next) => {
if (onPageChange) onPageChange(next);
if (controlledPage === undefined) setInternalPage(next);
};
// Snap back to page 1 whenever the result set changes underneath us,
// otherwise filtering to 3 rows while on page 4 shows an empty table.
const rowCount = rows.length;
React.useEffect(() => {
if (controlledPage === undefined) setInternalPage(1);
}, [rowCount, sort, controlledPage]);
const serverPaginated = totalItems != null && onPageChange != null;
const pageCount = paginated
? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize))
: 1;
const visibleRows = React.useMemo(() => {
if (!paginated || serverPaginated) return sortedRows;
const start = (page - 1) * pageSize;
return sortedRows.slice(start, start + pageSize);
}, [sortedRows, paginated, serverPaginated, page, pageSize]);
/* ── Selection ───────────────────────────────────────────────────────── */
const selectable = selectedIds !== undefined;
const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]);
const pageIds = visibleRows.map((row, i) => getRowId(row, i));
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id));
const someOnPageSelected = pageIds.some((id) => selected.has(id));
const toggleAll = () => {
if (allOnPageSelected) {
onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id)));
} else {
onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]);
}
};
const toggleRow = (id) => {
onSelectionChange?.(
selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id]
);
};
/* ── Render ──────────────────────────────────────────────────────────── */
const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' };
const alignClass = { right: 'text-right', center: 'text-center' };
if (loading) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
<SkeletonTable rows={initialPageSize && initialPageSize < 8 ? initialPageSize : 6} columns={columns.length} />
</Surface>
);
}
if (sortedRows.length === 0) {
return (
<Surface padding="none" radius="xl" className={cn('overflow-hidden', className)}>
{emptyState || (
<EmptyState
icon={Inbox}
variant={isFiltered ? 'filtered' : 'empty'}
title={isFiltered ? 'No matching results' : 'Nothing here yet'}
description={
isFiltered
? 'No rows match the current filters. Try widening or clearing them.'
: 'Rows will appear here once there is data.'
}
action={isFiltered && onClearFilters ? { label: 'Clear filters', onClick: onClearFilters } : undefined}
/>
)}
</Surface>
);
}
return (
<div className={cn('space-y-4', className)}>
<Surface padding="none" radius="xl" className="relative overflow-hidden">
<LoadingOverlay show={refreshing} />
<div className="overflow-x-auto">
<table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
<tr className="bg-surface-subtle border-b border-border">
{selectable && (
<th scope="col" className="w-10 px-4 py-3">
<Checkbox
checked={allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
onCheckedChange={toggleAll}
aria-label="Select all rows on this page"
/>
</th>
)}
{columns.map((column) => {
const active = sort?.key === column.key;
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
return (
<th
key={column.key}
scope="col"
style={column.width ? { width: column.width } : undefined}
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
className={cn(
'px-4 py-3 text-overline uppercase text-ink-4 font-heading whitespace-nowrap',
column.align ? alignClass[column.align] : 'text-left',
column.hideBelow && hideBelowClass[column.hideBelow]
)}
>
{column.sortable ? (
<button
type="button"
onClick={() => toggleSort(column)}
className={cn(
'inline-flex items-center gap-1.5 rounded-sm transition-colors hover:text-ink-2 focus-ring-inset',
active && 'text-krow-blue'
)}
>
{column.header}
<SortIcon className="w-3 h-3" aria-hidden="true" />
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{visibleRows.map((row, i) => {
const id = getRowId(row, i);
const isSelected = selected.has(id);
return (
<tr
key={id}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn(
'border-b border-border last:border-0 transition-colors duration-fast',
onRowClick && 'cursor-pointer',
isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle',
typeof rowClassName === 'function' ? rowClassName(row) : rowClassName
)}
>
{selectable && (
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRow(id)}
aria-label={`Select row ${i + 1}`}
/>
</td>
)}
{columns.map((column) => (
<td
key={column.key}
className={cn(
'px-4 py-3.5 text-body-sm text-ink-2 align-middle',
column.align ? alignClass[column.align] : 'text-left',
column.hideBelow && hideBelowClass[column.hideBelow],
column.className
)}
>
{column.cell
? column.cell(row)
: column.accessor
? column.accessor(row)
: row?.[column.key] ?? '—'}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</Surface>
{paginated && (
<Pagination
page={page}
pageCount={pageCount}
onPageChange={setPage}
totalItems={totalItems ?? sortedRows.length}
pageSize={pageSize}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
const WIDTHS = {
sm: 'w-full sm:w-80',
default: 'w-full sm:w-[28rem]',
lg: 'w-full sm:w-[36rem]',
xl: 'w-full sm:w-[44rem]',
};
/**
* Drawer — an edge-anchored panel for detail views, filters and mobile nav.
*
* Same header/body/footer contract as Modal, so the two are interchangeable:
* a detail view can be a Drawer on desktop and a bottom sheet on mobile without
* its contents changing.
*/
export function Drawer({
open,
onOpenChange,
title,
description,
icon: Icon,
side = 'right',
size = 'default',
footer,
children,
className,
/** Hides the header for a fully custom panel (the mobile nav does this). */
bare = false,
}) {
const isVertical = side === 'top' || side === 'bottom';
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side={side}
className={cn(
'p-0 gap-0 flex flex-col',
isVertical ? 'h-auto max-h-[85vh]' : WIDTHS[size],
className
)}
>
{!bare && (
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 space-y-0 text-left">
<div className="flex items-start gap-3">
{Icon && (
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<SheetTitle className="text-title font-heading text-ink-1">{title}</SheetTitle>
{description && (
<SheetDescription className="text-body-sm text-ink-3 mt-1">
{description}
</SheetDescription>
)}
</div>
</div>
</SheetHeader>
)}
<div className="flex-1 overflow-y-auto px-5 py-5">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-border bg-surface-subtle shrink-0 flex items-center justify-end gap-2">
{footer}
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
/**
* EmptyState — what a region shows when it has nothing to show.
*
* Deliberately distinguishes two situations that need different copy and
* different actions:
*
* - `empty` — nothing exists yet. Offer the action that creates the first one.
* - `filtered` — things exist but none match. Offer to clear the filters.
*
* Pages that conflate them end up telling a user with 22 candidates that they
* have no candidates.
*/
export function EmptyState({
icon: Icon,
title,
description,
/** Primary action — a <Button>, or `{ label, onClick }`. */
action,
secondaryAction,
variant = 'empty',
size = 'default',
className,
}) {
const renderAction = (spec, buttonVariant) => {
if (!spec) return null;
if (React.isValidElement(spec)) return spec;
return (
<Button variant={buttonVariant} size="sm" onClick={spec.onClick}>
{spec.icon && <spec.icon aria-hidden="true" />}
{spec.label}
</Button>
);
};
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center',
size === 'sm' ? 'py-8 px-4' : size === 'lg' ? 'py-20 px-6' : 'py-14 px-6',
className
)}
>
{Icon && (
<span
className={cn(
'grid place-items-center rounded-full mb-4',
variant === 'filtered' ? 'bg-surface-sunken text-ink-4' : 'bg-krow-blue-tint text-krow-blue',
size === 'sm' ? 'w-10 h-10' : 'w-14 h-14'
)}
>
<Icon className={size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'} aria-hidden="true" />
</span>
)}
<h3 className={cn('font-heading text-ink-1', size === 'sm' ? 'text-body' : 'text-title')}>
{title}
</h3>
{description && (
<p className="text-body-sm text-ink-3 mt-1.5 max-w-sm leading-relaxed">{description}</p>
)}
{(action || secondaryAction) && (
<div className="flex items-center gap-2 mt-5">
{renderAction(action, 'default')}
{renderAction(secondaryAction, 'outline')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Field — the label / hint / error scaffolding every form control shares.
*
* Controls stay presentational and Field owns the surrounding text, so label
* spacing, required markers and error styling are identical across the app.
* It wires `id`, `aria-describedby` and `aria-invalid` onto its child.
*/
export function Field({
label,
hint,
error,
required,
htmlFor,
/** Lays the label beside the control instead of above it (toggles, checkboxes). */
inline = false,
className,
children,
}) {
const reactId = React.useId();
const id = htmlFor || reactId;
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
// Only clone when the child is a single element that can accept the wiring.
const control = React.isValidElement(children)
? React.cloneElement(children, {
id: children.props.id || id,
'aria-describedby': children.props['aria-describedby'] || describedBy,
'aria-invalid': error ? true : children.props['aria-invalid'],
})
: children;
if (inline) {
return (
<div className={cn('flex items-start gap-3', className)}>
{control}
<div className="min-w-0">
{label && (
<label htmlFor={id} className="text-body-sm font-medium text-ink-2 cursor-pointer">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{hint && !error && <p id={hintId} className="text-caption text-ink-3 mt-0.5">{hint}</p>}
{error && <p id={errorId} className="text-caption text-destructive mt-0.5">{error}</p>}
</div>
</div>
);
}
return (
<div className={cn('space-y-1.5', className)}>
{label && (
<label htmlFor={id} className="block text-body-sm font-medium text-ink-2">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
)}
{control}
{hint && !error && <p id={hintId} className="text-caption text-ink-3">{hint}</p>}
{error && <p id={errorId} className="text-caption text-destructive">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput';
import { MultiSelect } from '@/components/ds/MultiSelect';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
/**
* FilterBar — the search + filters row above a list, table or grid.
*
* Filters are declared as data so every list page gets the same layout,
* the same responsive behaviour, and the same "Clear all" affordance:
*
* { key, label, type: 'select' | 'multi', options, placeholder, width }
*
* On mobile the filters collapse behind a "Filters" toggle with an active count,
* because four side-by-side selects are unusable at 375px.
*/
export function FilterBar({
search,
onSearchChange,
searchPlaceholder = 'Search...',
filters = [],
/** `{ [key]: string | string[] }` */
values = {},
onChange,
/** Right-aligned actions — primary buttons, view switchers. */
actions,
/** Rendered under the bar, e.g. a result count. */
meta,
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className,
}) {
const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => {
const value = values[filter.key];
if (filter.type === 'multi') return Array.isArray(value) && value.length > 0;
return value != null && value !== allValue && value !== '';
};
const activeFilters = filters.filter(isActive);
const activeCount = activeFilters.length + (search ? 1 : 0);
const set = (key, value) => onChange?.({ ...values, [key]: value });
const clearAll = () => {
const cleared = {};
filters.forEach((f) => {
cleared[f.key] = f.type === 'multi' ? [] : allValue;
});
onChange?.(cleared);
onSearchChange?.('');
};
const clearOne = (filter) => set(filter.key, filter.type === 'multi' ? [] : allValue);
const labelFor = (filter) => {
const value = values[filter.key];
if (filter.type === 'multi') {
return `${filter.label}: ${value.length}`;
}
const option = (filter.options || [])
.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
.find((o) => o.value === value);
return `${filter.label}: ${option?.label ?? value}`;
};
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{onSearchChange && (
<SearchInput
value={search}
onChange={onSearchChange}
placeholder={searchPlaceholder}
containerClassName="flex-1 min-w-0"
/>
)}
{/* Mobile: collapse the filter controls behind a toggle. */}
{filters.length > 0 && (
<Button
variant="outline"
shape="rounded"
onClick={() => setExpanded((v) => !v)}
className="sm:hidden justify-between"
>
<span className="inline-flex items-center gap-2">
<SlidersHorizontal aria-hidden="true" />
Filters
</span>
{activeFilters.length > 0 && (
<Badge variant="default" size="sm">{activeFilters.length}</Badge>
)}
</Button>
)}
<div
className={cn(
'flex flex-col sm:flex-row gap-3 sm:items-center',
expanded ? 'flex' : 'hidden sm:flex'
)}
>
{filters.map((filter) => {
const value = values[filter.key];
if (filter.type === 'multi') {
return (
<MultiSelect
key={filter.key}
options={filter.options}
value={Array.isArray(value) ? value : []}
onChange={(next) => set(filter.key, next)}
placeholder={filter.placeholder || filter.label}
className={cn('sm:w-auto', filter.width || 'sm:min-w-44')}
/>
);
}
const options = (filter.options || []).map((o) =>
typeof o === 'string' ? { value: o, label: o } : o
);
return (
<Select
key={filter.key}
value={value ?? allValue}
onValueChange={(next) => set(filter.key, next)}
>
<SelectTrigger
aria-label={filter.label}
className={cn('sm:w-auto', filter.width || 'sm:min-w-40')}
>
<SelectValue placeholder={filter.placeholder || filter.label} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
})}
</div>
{actions && <div className="flex items-center gap-2 sm:ml-auto shrink-0">{actions}</div>}
</div>
{/* Active filter chips — the escape hatch when a list looks wrong and the
user cannot see why. */}
{(activeCount > 0 || meta) && (
<div className="flex flex-wrap items-center gap-2">
{meta && <span className="text-caption text-ink-3 mr-1">{meta}</span>}
{activeFilters.map((filter) => (
<button
key={filter.key}
type="button"
onClick={() => clearOne(filter)}
className="inline-flex items-center gap-1 rounded-full bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue hover:bg-[#DBEAFE] transition-colors focus-ring"
>
{labelFor(filter)}
<X className="w-3 h-3" aria-hidden="true" />
</button>
))}
{activeCount > 1 && (
<button
type="button"
onClick={clearAll}
className="text-caption font-medium text-ink-3 hover:text-ink-1 hover:underline transition-colors focus-ring rounded-sm"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
/**
* IconButton — a square Button carrying only an icon.
*
* It delegates entirely to Button so the two can never diverge, and requires a
* `label`: an icon-only control with no accessible name is a bug, so the API
* makes it impossible to omit. The label doubles as the tooltip.
*/
export const IconButton = React.forwardRef(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {
xs: 'icon-xs',
sm: 'icon-sm',
default: 'icon',
lg: 'icon-lg',
};
const button = (
<Button ref={ref} size={sizeMap[size] || 'icon'} aria-label={label} {...props}>
<Icon aria-hidden="true" />
</Button>
);
if (!tooltip) return button;
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={side}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
);
IconButton.displayName = 'IconButton';

View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Surface } from '@/components/ds/Surface';
import { Skeleton } from '@/components/ds/Skeleton';
const TONES = {
brand: { tile: 'bg-krow-blue text-white', glow: 'shadow-brand', value: 'text-krow-blue' },
navy: { tile: 'bg-krow-navy text-white', glow: 'shadow-md', value: 'text-krow-blue' },
accent: { tile: 'bg-krow-yellow text-krow-blue', glow: 'shadow-[0_0_24px_rgba(249,229,71,0.35)]', value: 'text-krow-blue' },
success: { tile: 'bg-success-muted text-success', glow: 'shadow-xs', value: 'text-ink-1' },
warning: { tile: 'bg-warning-muted text-warning', glow: 'shadow-xs', value: 'text-ink-1' },
neutral: { tile: 'bg-surface-sunken text-ink-3', glow: 'shadow-xs', value: 'text-ink-1' },
};
/**
* KpiCard — one headline number in a dashboard row.
*
* Deliberately minimal: icon tile, value, label. When a number needs context
* (a target, a breakdown, a sparkline) use `MetricCard`; keeping the two
* separate stops KPI rows from drifting into inconsistent heights.
*/
export function KpiCard({
icon: Icon,
value,
label,
tone = 'brand',
/** Stagger position in a row — drives the entrance delay. */
index = 0,
loading = false,
onClick,
className,
}) {
const t = TONES[tone] || TONES.brand;
if (loading) {
return (
<Surface radius="xl" padding="sm" className={className}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
<Skeleton className="h-7 w-14 mb-2" />
<Skeleton className="h-2.5 w-20" />
</Surface>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06, duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
radius="xl"
padding="sm"
className={cn('w-full text-left hover:shadow-lg', className)}
>
{Icon && (
<div className={cn('grid place-items-center w-10 h-10 rounded-xl mb-3', t.tile, t.glow)}>
<Icon className="w-5 h-5" aria-hidden="true" />
</div>
)}
{/* Keyed so the number re-animates when it changes, which draws the eye
to the figure that actually moved. */}
<motion.div
key={value}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.06 + 0.2, type: 'spring', stiffness: 200 }}
className={cn('text-display font-heading font-bold tabular-nums', t.value)}
>
{value}
</motion.div>
<div className="text-overline text-ink-4 mt-0.5 normal-case tracking-wide">{label}</div>
</Surface>
</motion.div>
);
}
/** Formats a delta as a signed percentage: 12 → "+12%". */
const formatDelta = (delta, suffix = '%') =>
`${delta > 0 ? '+' : ''}${delta}${suffix}`;
/**
* MetricCard — a number with supporting context: a trend, a target, a
* sub-caption, or a small chart.
*
* `invertTrend` exists because "up" is not always good — time-to-hire rising is
* bad, and colouring it green would be actively misleading.
*/
export function MetricCard({
label,
value,
/** Signed number; sign drives the arrow and colour. */
delta,
deltaSuffix = '%',
deltaLabel,
invertTrend = false,
sub,
icon: Icon,
tone = 'neutral',
/** A sparkline or mini chart, rendered under the value. */
chart,
loading = false,
onClick,
className,
}) {
const t = TONES[tone] || TONES.neutral;
if (loading) {
return (
<Surface radius="xl" padding="default" className={className}>
<Skeleton className="h-2.5 w-24 mb-3" />
<Skeleton className="h-8 w-20 mb-2" />
<Skeleton className="h-2.5 w-32" />
</Surface>
);
}
const hasDelta = typeof delta === 'number' && delta !== 0;
const isGood = invertTrend ? delta < 0 : delta > 0;
const TrendIcon = !hasDelta ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<Surface
as={onClick ? 'button' : 'div'}
onClick={onClick}
interactive={onClick ? true : undefined}
radius="xl"
padding="default"
className={cn('w-full text-left', className)}
>
<div className="flex items-start justify-between gap-3 mb-3">
<p className="text-body-sm text-ink-3 font-medium">{label}</p>
{Icon && (
<span className={cn('grid place-items-center w-8 h-8 rounded-lg shrink-0', t.tile)}>
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
)}
</div>
<div className="flex items-end gap-2.5 flex-wrap">
<span className="text-display font-heading font-bold text-ink-1 tabular-nums leading-none">
{value}
</span>
{(hasDelta || delta === 0) && (
<span
className={cn(
'inline-flex items-center gap-0.5 text-caption font-semibold rounded-full px-1.5 py-0.5 mb-0.5',
!hasDelta
? 'bg-neutral-muted text-ink-3'
: isGood
? 'bg-success-muted text-success'
: 'bg-destructive-muted text-destructive'
)}
>
<TrendIcon className="w-3 h-3" aria-hidden="true" />
{hasDelta ? formatDelta(delta, deltaSuffix) : 'No change'}
</span>
)}
</div>
{(deltaLabel || sub) && (
<p className="text-caption text-ink-4 mt-2">{deltaLabel || sub}</p>
)}
{chart && <div className="mt-4">{chart}</div>}
</Surface>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Layout helpers.
*
* These exist so vertical rhythm and grid behaviour are declared once. The
* reference pages each invented their own spacing (`space-y-5`, `space-y-6`,
* `space-y-8` on sibling pages), which is exactly the drift a design system is
* supposed to prevent.
*/
const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
/**
* Grid — responsive column layouts with the app's standard breakpoints.
*
* `cols` is the count at the largest breakpoint; the intermediate steps are
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
*/
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
const columns = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6',
}[cols];
return (
<div className={cn('grid', columns, GAPS[gap], className)} {...props}>
{children}
</div>
);
}
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
export function Stack({ space = 'default', className, children, ...props }) {
const spacing = {
xs: 'space-y-2',
sm: 'space-y-3',
default: 'space-y-5',
lg: 'space-y-6',
xl: 'space-y-8',
}[space];
return (
<div className={cn(spacing, className)} {...props}>
{children}
</div>
);
}
/**
* PageSection — a titled region of a page. Pairs a SectionHeader-style label
* with its content at consistent spacing.
*/
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
return (
<section className={cn('space-y-3', className)}>
{title && (
<div className="flex items-center justify-between gap-3">
{level === 'overline' ? (
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
) : (
<div>
<h2 className="text-title-lg font-heading text-ink-1">{title}</h2>
{subtitle && <p className="text-body-sm text-ink-3 mt-0.5">{subtitle}</p>}
</div>
)}
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
)}
{children}
</section>
);
}

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Spinner — the only spinner in the system.
*/
export function Spinner({ size = 'default', className, label = 'Loading' }) {
const sizes = { xs: 'w-3.5 h-3.5', sm: 'w-4 h-4', default: 'w-6 h-6', lg: 'w-8 h-8' };
return (
<Loader2
role="status"
aria-label={label}
className={cn('animate-spin text-ink-4', sizes[size] || sizes.default, className)}
/>
);
}
/**
* LoadingState — a centred spinner with optional copy, for a region whose
* shape is not known ahead of time.
*
* Prefer `Skeleton*` when the shape *is* known: a skeleton that matches the
* final layout prevents the content jump a spinner always causes.
*/
export function LoadingState({ message, size = 'default', className }) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3',
size === 'sm' ? 'py-8' : size === 'lg' ? 'py-24' : 'py-16',
className
)}
>
<Spinner size={size === 'sm' ? 'default' : 'lg'} />
{message && <p className="text-body-sm text-ink-3">{message}</p>}
</div>
);
}
/**
* InlineLoading — a spinner and label on one line, for inside buttons rows,
* card headers, or beside a control that is refreshing.
*/
export function InlineLoading({ message = 'Loading…', className }) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" />
{message}
</span>
);
}
/**
* LoadingOverlay — covers content that is being replaced, keeping layout
* height stable so the page does not jump. Used by DataTable when refetching.
*/
export function LoadingOverlay({ show, message, className }) {
if (!show) return null;
return (
<div
className={cn(
'absolute inset-0 z-10 grid place-items-center rounded-[inherit]',
'bg-white/60 backdrop-blur-[2px] animate-fade-in',
className
)}
>
<div className="flex flex-col items-center gap-2">
<Spinner />
{message && <p className="text-caption text-ink-3">{message}</p>}
</div>
</div>
);
}

120
src/components/ds/Modal.jsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
const WIDTHS = {
sm: 'sm:max-w-sm',
default: 'sm:max-w-lg',
lg: 'sm:max-w-2xl',
xl: 'sm:max-w-4xl',
};
/**
* Modal — the standard dialog shape: header, scrollable body, footer.
*
* Owning the scroll boundary here is the point. A dialog that lets its whole
* body scroll pushes the title and the confirm button off-screen on a laptop;
* this keeps both pinned and scrolls only the middle.
*/
export function Modal({
open,
onOpenChange,
title,
description,
icon: Icon,
size = 'default',
/** Footer content. Omit for a dialog whose body carries its own actions. */
footer,
children,
className,
/** Blocks closing — use while a submit is in flight. */
busy = false,
}) {
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
className={cn('p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col', WIDTHS[size], className)}
onInteractOutside={busy ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={busy ? (e) => e.preventDefault() : undefined}
>
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border shrink-0 space-y-0">
<div className="flex items-start gap-3">
{Icon && (
<span className="grid place-items-center w-9 h-9 rounded-xl bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4.5 h-4.5" aria-hidden="true" />
</span>
)}
<div className="min-w-0 text-left">
<DialogTitle className="text-title font-heading text-ink-1">{title}</DialogTitle>
{description && (
<DialogDescription className="text-body-sm text-ink-3 mt-1">
{description}
</DialogDescription>
)}
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
{footer && (
<DialogFooter className="px-6 py-4 border-t border-border bg-surface-subtle shrink-0">
{footer}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
/**
* ConfirmModal — a destructive or irreversible confirmation.
*
* Defaults to the destructive tone because that is what confirmation is almost
* always for, and it labels the action with the verb ("Delete") rather than
* "OK", so the button says what will happen.
*/
export function ConfirmModal({
open,
onOpenChange,
title = 'Are you sure?',
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
tone = 'destructive',
icon,
busy = false,
}) {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
icon={icon}
size="sm"
busy={busy}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange?.(false)} disabled={busy}>
{cancelLabel}
</Button>
<Button variant={tone === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm} loading={busy}>
{confirmLabel}
</Button>
</>
}
>
{null}
</Modal>
);
}

View File

@@ -0,0 +1,171 @@
import * as React from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { SearchInput } from '@/components/ds/SearchInput';
/**
* MultiSelect — pick several values from a list.
*
* Selections render as removable chips in the trigger. Above `searchThreshold`
* options a filter field appears, so short lists stay uncluttered and long ones
* stay usable. Options may be strings or `{ value, label, count }`.
*/
export function MultiSelect({
options = [],
value = [],
onChange,
placeholder = 'Select...',
/** Chips shown before collapsing into "+N more". */
maxChips = 2,
searchThreshold = 8,
disabled,
className,
align = 'start',
emptyMessage = 'No options',
}) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const normalized = React.useMemo(
() => options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o)),
[options]
);
const filtered = React.useMemo(() => {
if (!query) return normalized;
const q = query.toLowerCase();
return normalized.filter((o) => o.label.toLowerCase().includes(q));
}, [normalized, query]);
const labelFor = (v) => normalized.find((o) => o.value === v)?.label ?? v;
const toggle = (optionValue) => {
const next = value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue];
onChange?.(next);
};
const remove = (optionValue, event) => {
// The chip's X sits inside the trigger — don't open the popover.
event.stopPropagation();
onChange?.(value.filter((v) => v !== optionValue));
};
const visible = value.slice(0, maxChips);
const overflow = value.length - visible.length;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'flex min-h-10 w-full items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-left shadow-xs',
'transition-all duration-base ease-out',
'outline-none focus-visible:border-krow-blue focus-visible:ring-2 focus-visible:ring-krow-blue/20',
'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:opacity-60',
open && 'border-krow-blue ring-2 ring-krow-blue/20',
className
)}
>
<span className="flex flex-1 flex-wrap items-center gap-1 min-w-0">
{value.length === 0 && <span className="text-body-sm text-ink-4">{placeholder}</span>}
{visible.map((v) => (
<Badge key={v} variant="soft" size="lg" className="max-w-[160px] pr-1">
<span className="truncate">{labelFor(v)}</span>
<span
role="button"
tabIndex={-1}
aria-label={`Remove ${labelFor(v)}`}
onClick={(e) => remove(v, e)}
className="grid place-items-center w-3.5 h-3.5 rounded-full hover:bg-krow-blue/20 shrink-0"
>
<X className="w-2.5 h-2.5" aria-hidden="true" />
</span>
</Badge>
))}
{overflow > 0 && (
<Badge variant="neutral" size="lg">+{overflow} more</Badge>
)}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-ink-4 shrink-0 transition-transform duration-base',
open && 'rotate-180'
)}
aria-hidden="true"
/>
</button>
</PopoverTrigger>
<PopoverContent align={align} className="w-[--radix-popover-trigger-width] min-w-52 p-0">
{normalized.length >= searchThreshold && (
<div className="p-2 border-b border-border">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Filter options"
size="sm"
debounce={0}
autoFocus
/>
</div>
)}
<div className="max-h-64 overflow-y-auto p-1.5">
{filtered.length === 0 && (
<p className="px-2 py-6 text-center text-body-sm text-ink-3">{emptyMessage}</p>
)}
{filtered.map((option) => {
const selected = value.includes(option.value);
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
onClick={() => toggle(option.value)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-body-sm transition-colors duration-fast',
'hover:bg-krow-blue-tint focus-ring-inset',
selected ? 'text-krow-blue font-medium' : 'text-ink-2'
)}
>
<span
className={cn(
'grid place-items-center w-4 h-4 rounded border shrink-0 transition-colors duration-fast',
selected ? 'bg-krow-blue border-krow-blue text-white' : 'border-border'
)}
>
{selected && <Check className="w-3 h-3" aria-hidden="true" />}
</span>
<span className="flex-1 truncate">{option.label}</span>
{option.count != null && (
<span className="text-caption text-ink-4 tabular-nums">{option.count}</span>
)}
</button>
);
})}
</div>
{value.length > 0 && (
<div className="border-t border-border p-1.5">
<button
type="button"
onClick={() => onChange?.([])}
className="w-full rounded-md px-2 py-1.5 text-body-sm text-ink-3 hover:bg-surface-sunken hover:text-ink-1 transition-colors focus-ring-inset"
>
Clear selection
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,87 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
/**
* PageHeader — the top of every page: optional icon, title, subtitle, and a
* right-aligned action slot. Owning the entrance animation here is what keeps
* page transitions feeling like one product.
*/
export function PageHeader({
title,
subtitle,
icon: Icon,
/** Right-aligned actions — buttons, counts, filters. */
actions,
/** Rendered above the title, e.g. a Breadcrumb. */
breadcrumb,
/** Rendered below the subtitle, e.g. a Tabs row. */
children,
/** `display` for dashboard heroes, `title` for standard pages. */
size = 'title',
animate = true,
className,
}) {
const Wrapper = animate ? motion.div : 'div';
const motionProps = animate
? {
initial: { opacity: 0, y: -12 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
}
: {};
return (
<Wrapper {...motionProps} className={cn('space-y-4', className)}>
{breadcrumb}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
{Icon && (
<span className="grid place-items-center w-10 h-10 rounded-xl bg-krow-blue text-white shadow-brand shrink-0">
<Icon className="w-5 h-5" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<h1
className={cn(
'font-heading text-ink-1 truncate',
size === 'display' ? 'text-display' : 'text-title-lg'
)}
>
{title}
</h1>
{subtitle && <p className="text-body-sm text-ink-3 mt-1">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
{children}
</Wrapper>
);
}
/**
* A dashboard-style greeting header where part of the title is emphasized —
* "Good day. Let's hire smarter."
*/
export function HeroHeader({ lead, highlight, subtitle, actions, className }) {
return (
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className={cn('flex items-center justify-between flex-wrap gap-4', className)}
>
<div>
<h1 className="text-display font-heading text-ink-1">
{lead} {highlight && <span className="text-krow-blue">{highlight}</span>}
</h1>
{subtitle && <p className="text-body text-ink-3 mt-1">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
/**
* Builds a compact page list with ellipses: 1 … 4 5 6 … 20.
*
* Always keeps the first page, the last page, and a window around the current
* one, so the control's width stays stable as the user pages through.
*/
export function buildPageRange(current, total, siblings = 1) {
const totalSlots = siblings * 2 + 5;
if (total <= totalSlots) return Array.from({ length: total }, (_, i) => i + 1);
const left = Math.max(current - siblings, 1);
const right = Math.min(current + siblings, total);
const showLeftEllipsis = left > 2;
const showRightEllipsis = right < total - 1;
const pages = [1];
if (showLeftEllipsis) pages.push('…');
for (let p = Math.max(2, left); p <= Math.min(total - 1, right); p += 1) pages.push(p);
if (showRightEllipsis) pages.push('…');
pages.push(total);
return pages;
}
/**
* Pagination — page navigation with an optional result summary and page-size
* control. Renders nothing when there is only one page.
*/
export function Pagination({
page = 1,
pageCount = 1,
onPageChange,
/** Total rows across all pages — enables the "Showing x–y of z" summary. */
totalItems,
pageSize,
onPageSizeChange,
pageSizeOptions = [10, 25, 50, 100],
className,
}) {
if (pageCount <= 1 && !onPageSizeChange) return null;
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;
const to = totalItems != null && pageSize ? Math.min(page * pageSize, totalItems) : null;
const range = buildPageRange(page, pageCount);
return (
<nav
aria-label="Pagination"
className={cn('flex flex-wrap items-center justify-between gap-3', className)}
>
<div className="flex items-center gap-3">
{from != null && (
<p className="text-caption text-ink-3 tabular-nums">
Showing <span className="font-medium text-ink-2">{from}–{to}</span> of{' '}
<span className="font-medium text-ink-2">{totalItems}</span>
</p>
)}
{onPageSizeChange && (
<label className="flex items-center gap-1.5 text-caption text-ink-3">
<span className="hidden sm:inline">Rows</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-7 rounded-md border border-border bg-surface pl-2 pr-6 text-caption text-ink-2 focus-ring-inset cursor-pointer"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
)}
</div>
{pageCount > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
shape="rounded"
aria-label="Previous page"
disabled={page <= 1}
onClick={() => onPageChange?.(page - 1)}
>
<ChevronLeft aria-hidden="true" />
</Button>
{range.map((entry, i) =>
entry === '…' ? (
<span key={`gap-${i}`} className="px-1.5 text-caption text-ink-4" aria-hidden="true">
…
</span>
) : (
<Button
key={entry}
variant={entry === page ? 'default' : 'ghost'}
size="icon-sm"
shape="rounded"
aria-label={`Page ${entry}`}
aria-current={entry === page ? 'page' : undefined}
onClick={() => onPageChange?.(entry)}
className="tabular-nums"
>
{entry}
</Button>
)
)}
<Button
variant="outline"
size="icon-sm"
shape="rounded"
aria-label="Next page"
disabled={page >= pageCount}
onClick={() => onPageChange?.(page + 1)}
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,255 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const clamp = (n) => Math.max(0, Math.min(100, Number(n) || 0));
const TRACK_TONES = {
brand: 'bg-krow-blue',
navy: 'bg-krow-navy',
accent: 'bg-krow-yellow',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
};
/**
* Score-band colour, shared by every progress component.
*
* The app scores people constantly, and the band boundaries (80/60/40) are the
* same ones the pipeline tiers use — so a 72 is the same colour on a bar, a ring
* and a leaderboard row.
*/
export function toneForScore(score) {
const s = clamp(score);
if (s >= 80) return 'brand';
if (s >= 60) return 'success';
if (s >= 40) return 'warning';
return 'destructive';
}
/**
* ProgressBar — a horizontal track.
*
* `tone="score"` picks the colour from the value, which is what most usages in
* this app want.
*/
export function ProgressBar({
value,
max = 100,
tone = 'brand',
size = 'default',
label,
showValue = false,
valueSuffix = '',
className,
trackClassName,
}) {
const pct = clamp((Number(value) / max) * 100);
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
const heights = { xs: 'h-1', sm: 'h-1.5', default: 'h-2', lg: 'h-2.5' };
return (
<div className={className}>
{(label || showValue) && (
<div className="flex items-center justify-between gap-2 mb-1.5">
{label && <span className="text-caption font-medium text-ink-2">{label}</span>}
{showValue && (
<span className="text-caption font-semibold text-ink-1 tabular-nums">
{value}
{valueSuffix}
</span>
)}
</div>
)}
<div
role="progressbar"
aria-valuenow={clamp(value)}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label || undefined}
className={cn(
'w-full overflow-hidden rounded-full bg-surface-sunken',
heights[size] || heights.default,
trackClassName
)}
>
<div
className={cn(
'h-full rounded-full transition-all duration-slow ease-out',
TRACK_TONES[resolvedTone]
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
/**
* ProgressRing — a circular gauge.
*
* Supports the app's two scales: 0–100 percentages and the 300–850 career
* score, via `min`/`max`, so the KROW ID gauge and a completion ring are the
* same component.
*/
export function ProgressRing({
value,
min = 0,
max = 100,
size = 88,
strokeWidth = 8,
tone = 'brand',
/** Big text in the centre. Defaults to the value. */
label,
/** Small text above the label. */
caption,
/** Small text below the label. */
footnote,
className,
}) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
const resolvedTone = tone === 'score' ? toneForScore(ratio * 100) : tone;
const strokeColor = {
brand: '#0A39DF',
navy: '#333F48',
accent: '#F9E547',
success: 'hsl(142 71% 36%)',
warning: 'hsl(32 95% 44%)',
destructive: 'hsl(0 84% 60%)',
}[resolvedTone];
return (
<div
className={cn('relative inline-grid place-items-center shrink-0', className)}
style={{ width: size, height: size }}
role="img"
aria-label={`${label ?? value} ${caption ?? ''}`.trim()}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
className="text-surface-sunken"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${ratio * circumference} ${circumference}`}
className="transition-[stroke-dasharray] duration-slow ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center leading-none">
{caption && (
<span className="text-[8px] uppercase tracking-wide text-ink-4 font-medium">{caption}</span>
)}
<span
className="font-heading font-bold text-ink-1 tabular-nums"
style={{ fontSize: Math.max(12, size * 0.24) }}
>
{label ?? value}
</span>
{footnote && <span className="text-[8px] text-ink-4 font-medium mt-0.5">{footnote}</span>}
</div>
</div>
);
}
/**
* ProgressSteps — a linear multi-step indicator for wizards.
*
* Completed steps show a check rather than their number, so "done" is readable
* at a glance without relying on colour.
*/
export function ProgressSteps({ steps = [], current = 0, className, onStepClick }) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
<ol className={cn('flex items-center gap-2', className)}>
{normalized.map((step, i) => {
const done = i < current;
const active = i === current;
const clickable = onStepClick && i <= current;
return (
<li key={step.label ?? i} className="flex items-center gap-2 flex-1 last:flex-none">
<button
type="button"
disabled={!clickable}
onClick={clickable ? () => onStepClick(i) : undefined}
aria-current={active ? 'step' : undefined}
className={cn(
'flex items-center gap-2 min-w-0',
clickable ? 'cursor-pointer' : 'cursor-default'
)}
>
<span
className={cn(
'grid place-items-center w-6 h-6 rounded-full text-[11px] font-bold shrink-0 transition-colors duration-base',
done && 'bg-krow-blue text-white',
active && 'bg-krow-blue-tint text-krow-blue ring-2 ring-krow-blue',
!done && !active && 'bg-surface-sunken text-ink-4'
)}
>
{done ? <Check className="w-3 h-3" aria-hidden="true" /> : i + 1}
</span>
{step.label && (
<span
className={cn(
'text-caption font-medium truncate hidden sm:block',
active ? 'text-ink-1' : done ? 'text-ink-2' : 'text-ink-4'
)}
>
{step.label}
</span>
)}
</button>
{i < normalized.length - 1 && (
<span
aria-hidden="true"
className={cn(
'h-px flex-1 min-w-4 transition-colors duration-base',
done ? 'bg-krow-blue' : 'bg-border'
)}
/>
)}
</li>
);
})}
</ol>
);
}
/**
* ScoreMeter — a labelled bar row used in score breakdowns, where many
* dimensions are listed with a weight and a value.
*/
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2">
<span className="text-body-sm text-ink-2 truncate">{label}</span>
<span className="flex items-baseline gap-1.5 shrink-0">
{weight != null && <span className="text-[10px] text-ink-4">{weight}% weight</span>}
<span className="text-body-sm font-semibold text-ink-1 tabular-nums">{value}</span>
</span>
</div>
<ProgressBar value={value} tone={tone} size="sm" />
</div>
);
}

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { inputVariants } from '@/components/ui/input';
/**
* SearchInput — a text input with a leading search icon and a clear button.
*
* Debounces `onChange` by default so pages can filter on every keystroke
* without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates.
*/
export const SearchInput = React.forwardRef(
(
{
value,
onChange,
placeholder = 'Search...',
debounce = 200,
size = 'default',
shape = 'rounded',
className,
containerClassName,
onClear,
autoFocus,
...props
},
ref
) => {
// Local state keeps typing immediate; the debounced value is what escapes.
const [local, setLocal] = React.useState(value ?? '');
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
// Follow controlled resets (a Clear all in a FilterBar) without fighting typing.
React.useEffect(() => {
setLocal((prev) => (value !== undefined && value !== prev ? value : prev));
}, [value]);
React.useEffect(() => {
if (!debounce) return undefined;
if (local === (value ?? '')) return undefined;
const timer = setTimeout(() => onChangeRef.current?.(local), debounce);
return () => clearTimeout(timer);
}, [local, debounce, value]);
const handleChange = (event) => {
const next = event.target.value;
setLocal(next);
if (!debounce) onChange?.(next);
};
const clear = () => {
setLocal('');
onChange?.('');
onClear?.();
};
const iconInset = size === 'sm' ? 'left-2.5' : 'left-3.5';
const padLeft = size === 'sm' ? 'pl-8' : 'pl-10';
return (
<div className={cn('relative', containerClassName)}>
<Search
className={cn(
'absolute top-1/2 -translate-y-1/2 w-4 h-4 text-ink-4 pointer-events-none',
iconInset
)}
aria-hidden="true"
/>
<input
ref={ref}
type="search"
role="searchbox"
value={local}
onChange={handleChange}
placeholder={placeholder}
autoFocus={autoFocus}
className={cn(
inputVariants({ size, shape }),
padLeft,
'pr-9 [&::-webkit-search-cancel-button]:hidden',
className
)}
{...props}
/>
{local && (
<button
type="button"
onClick={clear}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 grid place-items-center w-5 h-5 rounded-full text-ink-4 hover:text-ink-2 hover:bg-surface-sunken transition-colors focus-ring-inset"
>
<X className="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</div>
);
}
);
SearchInput.displayName = 'SearchInput';

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* SectionHeader — the label row above a section or inside a card. Three levels
* so the hierarchy below a PageHeader stays predictable:
*
* - `overline` — a small all-caps group label ("INTERVIEW COMPLETION")
* - `card` — a card's own title row
* - `section` — a page-level section title
*/
export function SectionHeader({
title,
subtitle,
icon: Icon,
level = 'card',
actions,
/** Renders a "View all ›" affordance on the right. */
onViewAll,
viewAllLabel = 'View all',
className,
}) {
if (level === 'overline') {
return (
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>
<h2 className="text-overline uppercase text-ink-4 font-heading">{title}</h2>
{actions}
</div>
);
}
return (
<div
className={cn(
'flex items-start justify-between gap-3',
level === 'section' ? 'mb-4' : 'mb-5',
className
)}
>
<div className="flex items-start gap-2.5 min-w-0">
{Icon && (
<span className="grid place-items-center w-8 h-8 rounded-lg bg-krow-blue-tint text-krow-blue shrink-0">
<Icon className="w-4 h-4" aria-hidden="true" />
</span>
)}
<div className="min-w-0">
<h3
className={cn(
'font-heading text-ink-1 truncate',
level === 'section' ? 'text-title-lg' : 'text-title'
)}
>
{title}
</h3>
{subtitle && <p className="text-caption text-ink-3 mt-0.5">{subtitle}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{actions}
{onViewAll && (
<button
type="button"
onClick={onViewAll}
className="inline-flex items-center gap-1 text-caption font-medium text-krow-blue hover:underline focus-ring rounded-sm"
>
{viewAllLabel}
<ChevronRight className="w-3 h-3" aria-hidden="true" />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Skeleton — a loading placeholder.
*
* Uses a travelling shimmer rather than a pulse: on the app's translucent glass
* surfaces a pulsing block reads as a rendering glitch, while a shimmer reads as
* progress. The shimmer is disabled automatically under
* `prefers-reduced-motion` (see index.css).
*/
export function Skeleton({ className, radius = 'md', ...props }) {
return (
<div
aria-hidden="true"
className={cn(
'relative overflow-hidden bg-surface-sunken',
radius === 'full' ? 'rounded-full' : radius === 'lg' ? 'rounded-xl' : 'rounded-md',
'after:absolute after:inset-0 after:shimmer',
className
)}
{...props}
/>
);
}
/**
* The composed patterns below exist so pages never hand-assemble skeletons.
* Each mirrors the real component's geometry, which is what makes the swap from
* loading to loaded invisible.
*/
/** Paragraph placeholder. The last line is short, as real text is. */
export function SkeletonText({ lines = 3, className }) {
return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => (
<Skeleton
key={i}
className={cn('h-3', i === lines - 1 && lines > 1 ? 'w-2/3' : 'w-full')}
/>
))}
</div>
);
}
/** Matches KpiCard: icon tile, big number, caption. */
export function SkeletonKpi({ className }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-4 shadow-sm', className)}>
<Skeleton className="w-10 h-10 rounded-xl mb-3" />
<Skeleton className="h-7 w-14 mb-2" />
<Skeleton className="h-2.5 w-20" />
</div>
);
}
/** Matches a list row: avatar, two lines of text, trailing value. */
export function SkeletonRow({ className }) {
return (
<div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-2.5 w-24" />
</div>
<Skeleton className="h-6 w-10" />
</div>
);
}
/** Matches a card in a grid: title, meta, stat row. */
export function SkeletonCard({ className }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-5 shadow-sm', className)}>
<div className="flex items-center gap-3 mb-4">
<Skeleton className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
<Skeleton className="h-2.5 w-28 mb-4" />
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-border">
{[0, 1, 2].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-2 w-12" />
</div>
))}
</div>
</div>
);
}
/** Matches DataTable: header row plus n body rows. */
export function SkeletonTable({ rows = 5, columns = 4, className }) {
return (
<div className={cn('overflow-hidden', className)} role="status" aria-label="Loading table">
<div className="flex items-center gap-4 px-4 py-3 border-b border-border bg-surface-subtle">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className={cn('h-2.5', i === 0 ? 'w-32' : 'flex-1 max-w-24')} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex items-center gap-4 px-4 py-3.5 border-b border-border last:border-0">
{Array.from({ length: columns }).map((_, c) => (
<Skeleton key={c} className={cn('h-3', c === 0 ? 'w-32' : 'flex-1 max-w-24')} />
))}
</div>
))}
</div>
);
}
/** Matches ChartContainer: title, legend, plot area. */
export function SkeletonChart({ className, height = 240 }) {
return (
<div className={cn('glass-card border border-white/60 rounded-2xl p-6 shadow-sm', className)}>
<Skeleton className="h-3.5 w-40 mb-2" />
<Skeleton className="h-2.5 w-56 mb-6" />
<div className="flex items-end gap-3" style={{ height }}>
{[0.5, 0.8, 0.35, 0.95, 0.6, 0.75].map((h, i) => (
<Skeleton key={i} className="flex-1" style={{ height: `${h * 100}%` }} />
))}
</div>
</div>
);
}
/** n SkeletonRows inside a surface — the common "loading a list" case. */
export function SkeletonList({ rows = 5, className }) {
return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => (
<SkeletonRow key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
/**
* The domain's states, mapped to a tone and a human label in one place.
*
* This map is the reason StatusBadge exists: `ai_screened` appeared with three
* different colours and two different spellings across the reference pages.
* Adding a state here makes it correct everywhere at once.
*/
export const STATUS_MAP = {
/* Application lifecycle */
applied: { label: 'Applied', tone: 'neutral' },
ai_screened: { label: 'AI Screened', tone: 'info' },
shortlisted: { label: 'Shortlisted', tone: 'accent' },
interview: { label: 'Interviewing', tone: 'warning' },
hired: { label: 'Hired', tone: 'success' },
rejected: { label: 'Declined', tone: 'destructive' },
/* Job posting status */
active: { label: 'Hiring', tone: 'default' },
draft: { label: 'Draft', tone: 'neutral' },
paused: { label: 'Paused', tone: 'warning' },
closed: { label: 'Closed', tone: 'destructive' },
/* Staff status */
onboarding: { label: 'Onboarding', tone: 'warning' },
inactive: { label: 'Inactive', tone: 'neutral' },
/* Score bands — the same vocabulary talentHome.js uses */
elite: { label: 'Elite', tone: 'default' },
excellent: { label: 'Excellent', tone: 'success' },
solid: { label: 'Solid', tone: 'info' },
building: { label: 'Building', tone: 'warning' },
new: { label: 'New', tone: 'neutral' },
no_score: { label: 'No score yet', tone: 'neutral' },
/* Interview verdicts */
hire: { label: 'Hire', tone: 'success' },
maybe: { label: 'Maybe', tone: 'warning' },
no: { label: 'No', tone: 'destructive' },
/* Evidence verdicts */
verified: { label: 'Verified', tone: 'success' },
needs_work: { label: 'Needs work', tone: 'warning' },
failed: { label: 'Failed', tone: 'destructive' },
pending: { label: 'Pending', tone: 'neutral' },
expired: { label: 'Expired', tone: 'destructive' },
/* Profile tiers */
Skilled: { label: 'Skilled', tone: 'default' },
'Cross-Trained': { label: 'Cross-Trained', tone: 'info' },
Beginner: { label: 'Beginner', tone: 'neutral' },
};
/** Title-cases an unknown status so it never renders as a raw enum. */
const humanize = (status) =>
String(status).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const DOT_COLOR = {
default: 'bg-krow-blue',
soft: 'bg-krow-blue',
accent: 'bg-krow-blue',
navy: 'bg-krow-navy',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
info: 'bg-info',
neutral: 'bg-ink-4',
outline: 'bg-ink-4',
};
/**
* StatusBadge — renders a domain status with its canonical colour and wording.
*
* `dot` adds a leading indicator for dense rows where colour alone is doing too
* much work; it also gives colour-blind users a second cue alongside the label.
*/
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral';
const resolvedLabel = label || entry.label || humanize(status);
return (
<Badge variant={resolvedTone} size={size} className={cn('gap-1.5', className)} {...props}>
{dot && (
<span
className={cn('w-1.5 h-1.5 rounded-full shrink-0', DOT_COLOR[resolvedTone])}
aria-hidden="true"
/>
)}
{resolvedLabel}
</Badge>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
/**
* Surface — the canonical container in the design system.
*
* Every card, panel and boxed region in the app is a Surface, so radius,
* border, elevation and hover behaviour can only be defined in one place.
* `glass` is the default because the app sits on a gradient-mesh background
* that solid white cards flatten.
*/
const surfaceVariants = cva(
'transition-all duration-base ease-out',
{
variants: {
variant: {
glass: 'glass-card border border-white/60',
solid: 'bg-surface border border-border',
subtle: 'bg-surface-subtle border border-border',
sunken: 'bg-surface-sunken border border-transparent',
outline: 'bg-transparent border border-border',
/* The one dark surface — the Owliver panel */
panel: 'panel-surface border border-panel-border text-panel-foreground',
},
elevation: {
none: 'shadow-none',
xs: 'shadow-xs',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
},
radius: {
md: 'rounded-lg',
lg: 'rounded-xl',
xl: 'rounded-2xl',
},
padding: {
none: 'p-0',
sm: 'p-4',
default: 'p-5',
lg: 'p-6',
},
interactive: {
true: 'cursor-pointer hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0A39DF]/60 focus-visible:ring-offset-2',
},
},
defaultVariants: {
variant: 'glass',
elevation: 'sm',
radius: 'xl',
padding: 'default',
},
}
);
const Surface = React.forwardRef(
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
const Comp = as || 'div';
return (
<Comp
ref={ref}
className={cn(surfaceVariants({ variant, elevation, radius, padding, interactive }), className)}
{...props}
/>
);
}
);
Surface.displayName = 'Surface';
export { Surface, surfaceVariants };

119
src/components/ds/Tabs.jsx Normal file
View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
/**
* Tabs — switches between views of the same page.
*
* Two visual styles:
* - `pill` — matches the app's header navigation, with a sliding indicator
* - `underline` — quieter, for tabs inside a card
*
* The sliding indicator uses a shared `layoutId`, so the highlight animates
* between tabs the same way the main nav pill does. `layoutGroupId` must be
* unique when two tab sets are on screen at once, otherwise the indicator
* animates between them.
*/
export function Tabs({
tabs = [],
value,
onChange,
variant = 'pill',
size = 'default',
layoutGroupId,
className,
ariaLabel = 'Views',
}) {
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
const fallbackId = React.useId();
const groupId = layoutGroupId || fallbackId;
if (variant === 'underline') {
return (
<div role="tablist" aria-label={ariaLabel} className={cn('flex items-center gap-1 border-b border-border', className)}>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-2 px-3 pb-2.5 pt-1.5 font-medium transition-colors duration-base focus-ring-inset rounded-t-md',
size === 'sm' ? 'text-body-sm' : 'text-body',
active ? 'text-krow-blue' : 'text-ink-3 hover:text-ink-1'
)}
>
{tab.icon && <tab.icon className="w-4 h-4" aria-hidden="true" />}
{tab.label}
{tab.count != null && (
<Badge variant={active ? 'soft' : 'neutral'} size="sm">{tab.count}</Badge>
)}
{active && (
<motion.span
layoutId={`tab-underline-${groupId}`}
className="absolute left-0 right-0 -bottom-px h-0.5 rounded-full bg-krow-blue"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
</button>
);
})}
</div>
);
}
return (
<div
role="tablist"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-1 rounded-full bg-white/60 backdrop-blur-xl p-1 border border-white/50 shadow-sm',
className
)}
>
{normalized.map((tab) => {
const active = tab.value === value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange?.(tab.value)}
className={cn(
'relative inline-flex items-center gap-1.5 rounded-full font-medium transition-colors duration-base focus-ring',
size === 'sm' ? 'px-3 py-1 text-caption' : 'px-4 py-1.5 text-body-sm',
active ? 'text-white' : 'text-ink-3 hover:text-ink-1'
)}
>
{active && (
<motion.span
layoutId={`tab-pill-${groupId}`}
className="absolute inset-0 rounded-full bg-krow-blue"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5">
{tab.icon && <tab.icon className="w-3.5 h-3.5" aria-hidden="true" />}
{tab.label}
{tab.count != null && (
<span
className={cn(
'rounded-full px-1.5 text-[10px] font-bold tabular-nums',
active ? 'bg-white/25 text-white' : 'bg-surface-sunken text-ink-3'
)}
>
{tab.count}
</span>
)}
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,99 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { StatusBadge } from '@/components/ds/StatusBadge';
const TONE_DOT = {
brand: 'bg-krow-blue ring-krow-blue/20',
success: 'bg-success ring-success/20',
warning: 'bg-warning ring-warning/20',
destructive: 'bg-destructive ring-destructive/20',
neutral: 'bg-ink-4 ring-ink-4/20',
};
/**
* Timeline — a vertical sequence of events: a candidate's journey, an audit
* trail, a career history.
*
* Items:
* { id, title, description, timestamp, icon, tone, status, meta, current }
*
* The connecting rail is drawn per item rather than as one absolute line, so a
* timeline can be nested inside a scrolling card without the rail detaching.
*/
export function Timeline({ items = [], className, compact = false }) {
return (
<ol className={cn('relative', className)}>
{items.map((item, i) => {
const isLast = i === items.length - 1;
const Icon = item.icon;
const tone = item.tone || (item.current ? 'brand' : 'neutral');
return (
<li key={item.id ?? i} className="relative flex gap-3.5 pb-5 last:pb-0">
{/* Rail + node */}
<div className="relative flex flex-col items-center shrink-0">
{Icon ? (
<span
className={cn(
'grid place-items-center rounded-full ring-4 shrink-0 z-10',
compact ? 'w-6 h-6' : 'w-8 h-8',
tone === 'neutral' ? 'bg-surface-sunken text-ink-3 ring-white' : 'text-white ring-white',
tone !== 'neutral' && TONE_DOT[tone]
)}
>
<Icon className={compact ? 'w-3 h-3' : 'w-4 h-4'} aria-hidden="true" />
</span>
) : (
<span
className={cn(
'rounded-full ring-4 ring-white shrink-0 z-10 mt-1',
compact ? 'w-2 h-2' : 'w-2.5 h-2.5',
TONE_DOT[tone],
item.current && 'animate-pulse-ring'
)}
aria-hidden="true"
/>
)}
{!isLast && (
<span
aria-hidden="true"
className="w-px flex-1 bg-border mt-1.5 min-h-4"
/>
)}
</div>
{/* Content */}
<div className={cn('flex-1 min-w-0', compact ? '-mt-0.5' : '-mt-1')}>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div className="min-w-0">
<p
className={cn(
'font-medium text-ink-1',
compact ? 'text-body-sm' : 'text-body'
)}
>
{item.title}
</p>
{item.description && (
<p className="text-body-sm text-ink-3 mt-0.5 leading-relaxed">{item.description}</p>
)}
{item.meta && <p className="text-caption text-ink-4 mt-1">{item.meta}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
{item.status && <StatusBadge status={item.status} size="sm" />}
{item.timestamp && (
<time className="text-caption text-ink-4 whitespace-nowrap">{item.timestamp}</time>
)}
</div>
</div>
{item.children}
</div>
</li>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,112 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
/**
* Toggle — a labelled switch row for settings.
*
* Wraps the Switch primitive with the label/description layout it is almost
* always used with, and makes the whole row a label so the hit target is the
* full width rather than the 36px control.
*/
export function Toggle({
checked,
onCheckedChange,
label,
description,
disabled,
/** `end` puts the switch on the right (settings rows); `start` on the left. */
align = 'end',
className,
id: providedId,
}) {
const reactId = React.useId();
const id = providedId || reactId;
const descriptionId = description ? `${id}-description` : undefined;
const control = (
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
aria-describedby={descriptionId}
/>
);
const text = (
<span className="min-w-0">
<span className="block text-body-sm font-medium text-ink-2">{label}</span>
{description && (
<span id={descriptionId} className="block text-caption text-ink-3 mt-0.5">
{description}
</span>
)}
</span>
);
return (
<label
htmlFor={id}
className={cn(
'flex items-center gap-3 cursor-pointer select-none',
disabled && 'cursor-not-allowed opacity-60',
align === 'end' && 'justify-between',
className
)}
>
{align === 'start' ? (
<>
{control}
{text}
</>
) : (
<>
{text}
{control}
</>
)}
</label>
);
}
/**
* SegmentedToggle — a small pill group for switching between 2–4 mutually
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
*/
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn(
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
className
)}
>
{normalized.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange?.(option.value)}
className={cn(
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
active
? 'bg-krow-blue text-white shadow-sm'
: 'text-ink-3 hover:text-ink-1'
)}
>
{option.label}
</button>
);
})}
</div>
);
}

125
src/components/ds/index.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* KROW design system — the single import surface for building pages.
*
* import { PageHeader, KpiCard, DataTable, StatusBadge } from '@/components/ds';
*
* Two layers sit underneath:
*
* - `components/ui/*` — Radix-based primitives (the mechanics: focus traps,
* portals, keyboard handling). Restyled to the KROW tokens, not re-wrapped.
* - `components/ds/*` — the app's own composites, built from those primitives.
*
* Primitives that need no composition are re-exported here rather than
* duplicated, so there is exactly one Tooltip, one Popover and one Checkbox in
* the codebase and pages never need to know which layer a component came from.
*/
/* ── Foundation ─────────────────────────────────────────────────────────── */
export { Surface, surfaceVariants } from './Surface';
export { PageHeader, HeroHeader } from './PageHeader';
export { SectionHeader } from './SectionHeader';
/* ── Actions ────────────────────────────────────────────────────────────── */
export { Button, buttonVariants } from '@/components/ui/button';
export { IconButton } from './IconButton';
/* ── Forms & controls ───────────────────────────────────────────────────── */
export { Field } from './Field';
export { Input, inputVariants } from '@/components/ui/input';
export { Textarea } from '@/components/ui/textarea';
export { Label } from '@/components/ui/label';
export { SearchInput } from './SearchInput';
export { MultiSelect } from './MultiSelect';
export { Toggle, SegmentedToggle } from './Toggle';
export { Checkbox } from '@/components/ui/checkbox';
export { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
export { Switch } from '@/components/ui/switch';
export { Slider } from '@/components/ui/slider';
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/* ── Identity & status ──────────────────────────────────────────────────── */
export { Avatar, AvatarGroup, initialsFrom } from './Avatar';
export { Badge, badgeVariants } from '@/components/ui/badge';
export { StatusBadge, STATUS_MAP } from './StatusBadge';
/* ── Navigation ─────────────────────────────────────────────────────────── */
export { Tabs } from './Tabs';
export { Pagination, buildPageRange } from './Pagination';
export {
Breadcrumb,
BreadcrumbEllipsis,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
/* ── Overlays ───────────────────────────────────────────────────────────── */
export { Modal, ConfirmModal } from './Modal';
export { Drawer } from './Drawer';
export {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
/* ── Feedback ───────────────────────────────────────────────────────────── */
export { Alert } from './Alert';
export { toast } from './toast';
export { EmptyState } from './EmptyState';
export { LoadingState, LoadingOverlay, InlineLoading, Spinner } from './LoadingState';
export {
Skeleton,
SkeletonCard,
SkeletonChart,
SkeletonKpi,
SkeletonList,
SkeletonRow,
SkeletonTable,
SkeletonText,
} from './Skeleton';
/* ── Data display ───────────────────────────────────────────────────────── */
export { KpiCard, MetricCard } from './KpiCard';
export { DataTable } from './DataTable';
export { FilterBar } from './FilterBar';
export { Timeline } from './Timeline';
export { ActivityCard } from './ActivityCard';
export {
AXIS_PROPS,
CHART_COLORS,
CHART_TONES,
ChartContainer,
ChartLegend,
ChartTooltip,
} from './ChartContainer';
export {
ProgressBar,
ProgressRing,
ProgressSteps,
ScoreMeter,
toneForScore,
} from './Progress';
/* ── Layout helpers ─────────────────────────────────────────────────────── */
export { Grid, PageSection, Stack } from './Layout';

View File

@@ -0,0 +1,80 @@
import { toast as hotToast } from 'react-hot-toast';
/**
* Toast — transient confirmation of a user action.
*
* The app already depends on react-hot-toast (13 call sites), so this wraps it
* rather than introducing a second toast system. Wrapping buys three things:
* one place to set styling so toasts match the design system, a named API
* (`toast.success` / `toast.error` / `toast.promise`) instead of ad-hoc option
* objects, and a single default duration.
*
* Rule of thumb: toast for "that worked", `Alert` for "here is a condition you
* need to keep seeing", `ConfirmModal` for "are you sure".
*/
const BASE_STYLE = {
borderRadius: '12px',
background: '#FFFFFF',
color: 'hsl(215 25% 27%)',
fontSize: '13px',
fontWeight: 500,
padding: '10px 14px',
boxShadow: '0 12px 28px -6px rgb(16 24 40 / 0.12), 0 4px 10px -4px rgb(16 24 40 / 0.06)',
border: '1px solid hsl(150 8% 87%)',
maxWidth: '420px',
};
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
const withDefaults = (options = {}) => ({
position: 'bottom-center',
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});
export const toast = {
success(message, options) {
return hotToast.success(message, {
duration: DURATIONS.success,
iconTheme: { primary: 'hsl(142 71% 36%)', secondary: '#FFFFFF' },
...withDefaults(options),
});
},
error(message, options) {
return hotToast.error(message, {
duration: DURATIONS.error,
iconTheme: { primary: 'hsl(0 84% 60%)', secondary: '#FFFFFF' },
...withDefaults(options),
});
},
info(message, options) {
return hotToast(message, { duration: DURATIONS.info, ...withDefaults(options) });
},
loading(message, options) {
return hotToast.loading(message, { duration: DURATIONS.loading, ...withDefaults(options) });
},
/**
* Ties a toast to an async action: loading while it runs, then success or
* error. Preferred over a manual loading/dismiss pair, which leaks a spinner
* whenever the promise rejects.
*/
promise(promise, messages, options) {
return hotToast.promise(
promise,
{
loading: messages?.loading ?? 'Working…',
success: messages?.success ?? 'Done',
error: messages?.error ?? 'Something went wrong',
},
withDefaults(options)
);
},
dismiss: hotToast.dismiss,
remove: hotToast.remove,
};