chore(ts-migration): migrate UI primitives and design system to TypeScript

Phase 6. 57 files: 23 vendored shadcn primitives, 30 design-system components,
4 charts. Plus `src/components/ds/props.ts`, which is types only.

Renaming these alone took typecheck from 37 to 1008, and the reason is worth
recording because it is the shape of every remaining phase.

These components had NO prop contract. No PropTypes, no validation: in the
JavaScript every prop was optional and every extra prop was spread onto the
underlying element. TypeScript infers a destructured parameter WITHOUT a default
as REQUIRED, so the moment the files became `.tsx` it invented a rule the
components never had and rejected several hundred call sites that have always
worked. That is the compiler describing its own inference, not a defect it
found.

Three mechanical fixes, each restoring a contract that already existed:

  - 57 JSDoc `@type {React.ForwardRefExoticComponent<any>}` annotations become
    real TypeScript annotations. Those comments were the previous authors'
    deliberate compatibility types; JSDoc stops applying in a `.tsx` file, so
    converting them preserves an intent that was already written down.

  - 61 `React.forwardRef(...)` calls gain `<any, any>`. Without generics `ref`
    infers `ForwardedRef<unknown>`, which no element's `Ref<T>` accepts - so
    every primitive that forwards a ref to a `div` failed on the ref, not the
    props.

  - 78 component signatures take `DsProps`, a documented alias for
    `Record<string, any>`. It exists so the decision is recorded once and is
    greppable when someone tightens it, rather than being 78 bare `any`s with
    no explanation between them. The prop NAMES are not lost: every component
    still destructures them by name, which is where a reader looks.

Four files needed real types rather than compatibility ones. `ds/toast` takes
react-hot-toast's own `ToastOptions`, which narrows `position` to its
`ToastPosition` union instead of widening to `string` - the widening was what
made all six calls unassignable. `ds/Pagination`'s page range is genuinely
`(number | string)[]`, because it interleaves page numbers with '…' markers that
the renderer tests for. `ds/Field` narrows `children.props` at three reads, and
`ds/Avatar` needed the ref generic.

Two of my own automated passes were wrong and were caught rather than shipped. A
props-interface generator dropped alternating props, because non-overlapping
regex matches consume the separating comma - it made things worse (83 file
errors to 146) and was reverted wholesale. A second pass missed every
multi-line signature whose defaults contain a `)`, such as `onClose = () => {}`;
that needed a brace matcher rather than a character class.

56 of 57 files emit byte-identical JavaScript. The one exception is `ds/toast`,
where a JSDoc type CAST - `/** @type {ToastPosition} */ ('bottom-center')` -
became a real annotation, so the emitted output loses a comment and a pair of
now-redundant parentheses. The value is `"bottom-center"` either way; the
minified outputs differ only in esbuild's choice of mangled local names.

Verified: tsc 37 -> 35, set-difference showing zero introduced and two removed;
zero errors remain in any Phase 6 file; npm test 1684/1691 with the same seven
failures; lint 0 errors; build succeeds with the API origin inlined; baseline
artifacts untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-17 23:59:49 +05:30
parent 2b8f5746bd
commit d440036211
58 changed files with 191 additions and 185 deletions

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Avatar } from '@/components/ds/Avatar';
@@ -30,7 +31,7 @@ export function ActivityCard({
variant = 'row',
onClick,
className,
}) {
}: DsProps) {
const iconTones = {
soft: 'bg-krow-blue-tint text-krow-blue',
brand: 'bg-krow-blue text-white',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';
import { cva } from 'class-variance-authority';
@@ -40,7 +41,7 @@ const ICON_COLORS = {
* that persists is noise.
*/
/** @param {any} props */
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }) {
export function Alert({ tone = 'info', title, children, icon, onDismiss, action, className, ...props }: DsProps) {
const Icon = icon || ICONS[tone];
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
@@ -41,8 +42,7 @@ export function initialsFrom(name = '') {
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const Avatar = React.forwardRef(
export const Avatar: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
@@ -91,7 +91,7 @@ Avatar.displayName = 'Avatar';
* people in a tight row (applicants on a position, endorsers on a skill).
*/
/** @param {any} props */
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }: DsProps) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ResponsiveContainer } from 'recharts';
import { cn } from '@/lib/utils';
@@ -38,7 +39,7 @@ export const AXIS_PROPS = {
* white box that ignores the token set.
*/
/** @param {any} props */
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }) {
export function ChartTooltip({ active, payload, label, valueFormatter, labelFormatter }: DsProps) {
if (!active || !payload?.length) return null;
return (
@@ -71,7 +72,7 @@ export function ChartTooltip({ active, payload, label, valueFormatter, labelForm
* A legend that reads as part of the card rather than as chart furniture.
* Items: `{ label, color, value? }`.
*/
export function ChartLegend({ items = [], className }) {
export function ChartLegend({ items = [], className }: DsProps) {
return (
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
{items.map((item) => (
@@ -118,7 +119,7 @@ export function ChartContainer({
raw = false,
footer,
className,
}) {
}: DsProps) {
if (loading) return <SkeletonChart className={className} height={height} />;
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -60,7 +61,7 @@ export function DataTable({
className,
rowClassName,
stickyHeader = false,
}) {
}: DsProps) {
/* ── Sorting ─────────────────────────────────────────────────────────── */
/* Row height follows the account's density preference. Only the vertical
padding changes — same columns, same type, same behaviour. */

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
@@ -41,7 +42,7 @@ export function Drawer({
className,
/** Hides the header for a fully custom panel (the mobile nav does this). */
bare = false,
}) {
}: DsProps) {
const isVertical = side === 'top' || side === 'bottom';
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -25,7 +26,7 @@ export function EmptyState({
variant = 'empty',
size = 'default',
className,
}) {
}: DsProps) {
const renderAction = (spec, buttonVariant) => {
if (!spec) return null;
if (React.isValidElement(spec)) return spec;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -19,7 +20,7 @@ export function Field({
inline = false,
className,
children,
}) {
}: DsProps) {
const reactId = React.useId();
const id = htmlFor || reactId;
const hintId = hint ? `${id}-hint` : undefined;
@@ -27,11 +28,15 @@ export function Field({
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
// Only clone when the child is a single element that can accept the wiring.
/* `isValidElement` narrows to `ReactElement<unknown>`, whose `props` is
`unknown` — so reading `children.props.id` to preserve a caller's own id
does not compile. The cast says what the guard has already established and
what the runtime relies on: this is an element with props. */
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'],
? React.cloneElement(children as React.ReactElement<Record<string, any>>, {
id: (children.props as Record<string, any>).id || id,
'aria-describedby': (children.props as Record<string, any>)['aria-describedby'] || describedBy,
'aria-invalid': error ? true : (children.props as Record<string, any>)['aria-invalid'],
})
: children;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -37,7 +38,7 @@ export function FilterBar({
/** Value that means "no filter" for select-type filters. */
allValue = 'all',
className = '',
}) {
}: DsProps) {
const [expanded, setExpanded] = React.useState(false);
const isActive = (filter) => {

View File

@@ -9,8 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
* `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.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const IconButton = React.forwardRef(
export const IconButton: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ icon: Icon, label, tooltip = true, size = 'default', side = 'top', ...props }, ref) => {
const sizeMap = {
xs: 'icon-xs',

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ export function InsightRow({
onClick = null,
actionLabel = 'Review',
className = '',
}) {
}: DsProps) {
const s = SEVERITY[severity] || SEVERITY.info;
const Comp = onClick ? 'button' : 'div';
@@ -107,6 +108,6 @@ export function InsightRow({
/** The list container — dividers between rows, nothing else. */
/** @param {any} props */
export function InsightList({ children, className = '' }) {
export function InsightList({ children, className = '' }: DsProps) {
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
@@ -32,7 +33,7 @@ export function KpiCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.brand;
if (loading) {
@@ -112,7 +113,7 @@ export function MetricCard({
loading = false,
onClick,
className,
}) {
}: DsProps) {
const t = TONES[tone] || TONES.neutral;
if (loading) {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -19,7 +20,7 @@ const GAPS = { sm: 'gap-3', default: 'gap-4', lg: 'gap-6' };
* chosen to avoid orphan cards (6 → 2/3/6 rather than 1/3/6).
*/
/** @param {any} props */
export function Grid({ cols = 3, gap = 'default', className, children, ...props }) {
export function Grid({ cols = 3, gap = 'default', className, children, ...props }: DsProps) {
const columns = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
@@ -38,7 +39,7 @@ export function Grid({ cols = 3, gap = 'default', className, children, ...props
/** Stack — vertical rhythm. `space` maps to the two spacings pages should use. */
/** @param {any} props */
export function Stack({ space = 'default', className, children, ...props }) {
export function Stack({ space = 'default', className, children, ...props }: DsProps) {
const spacing = {
xs: 'space-y-2',
sm: 'space-y-3',
@@ -59,7 +60,7 @@ export function Stack({ space = 'default', className, children, ...props }) {
* with its content at consistent spacing.
*/
/** @param {any} props */
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }) {
export function PageSection({ title, subtitle, actions, level = 'overline', className, children }: DsProps) {
return (
<section className={cn('space-y-3', className)}>
{title && (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -6,7 +7,7 @@ import { cn } from '@/lib/utils';
* Spinner — the only spinner in the system.
*/
/** @param {any} props */
export function Spinner({ size = 'default', className = '', label = 'Loading' }) {
export function Spinner({ size = 'default', className = '', label = 'Loading' }: DsProps) {
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
@@ -25,7 +26,7 @@ export function Spinner({ size = 'default', className = '', label = 'Loading' })
* final layout prevents the content jump a spinner always causes.
*/
/** @param {any} props */
export function LoadingState({ message = '', size = 'default', className = '' }) {
export function LoadingState({ message = '', size = 'default', className = '' }: DsProps) {
return (
<div
className={cn(
@@ -45,7 +46,7 @@ export function LoadingState({ message = '', size = 'default', className = '' })
* card headers, or beside a control that is refreshing.
*/
/** @param {any} props */
export function InlineLoading({ message = 'Loading…', className = '' }) {
export function InlineLoading({ message = 'Loading…', className = '' }: DsProps) {
return (
<span className={cn('inline-flex items-center gap-2 text-body-sm text-ink-3', className)}>
<Spinner size="sm" />
@@ -59,7 +60,7 @@ export function InlineLoading({ message = 'Loading…', className = '' }) {
* height stable so the page does not jump. Used by DataTable when refetching.
*/
/** @param {any} props */
export function LoadingOverlay({ show = false, message = '', className = '' }) {
export function LoadingOverlay({ show = false, message = '', className = '' }: DsProps) {
if (!show) return null;
return (
<div

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -58,7 +59,7 @@ function Delta({ delta, label, invert }) {
}
/** @param {any} props */
export function MetricStrip({ items = [], columns, className, loading = false }) {
export function MetricStrip({ items = [], columns, className, loading = false }: DsProps) {
const count = columns ?? Math.min(items.length, 6);
const gridCols = {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
@@ -39,7 +40,7 @@ export function Modal({
className,
/** Blocks closing — use while a submit is in flight. */
busy = false,
}) {
}: DsProps) {
return (
<Dialog open={open} onOpenChange={busy ? undefined : onOpenChange}>
<DialogContent
@@ -100,7 +101,7 @@ export function ConfirmModal({
tone = 'destructive',
icon = null,
busy = false,
}) {
}: DsProps) {
return (
<Modal
open={open}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -25,7 +26,7 @@ export function MultiSelect({
className = '',
align = 'start',
emptyMessage = 'No options',
}) {
}: DsProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -22,7 +23,7 @@ export function PageHeader({
size = 'title',
animate = true,
className,
}) {
}: DsProps) {
const Wrapper = animate ? motion.div : 'div';
const motionProps = animate
? {
@@ -68,7 +69,7 @@ export function PageHeader({
* 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 }) {
export function HeroHeader({ lead, highlight, subtitle, actions, className }: DsProps) {
return (
<motion.div
initial={{ opacity: 0, y: -12 }}

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -24,7 +25,10 @@ export function buildPageRange(current, total, siblings = 1) {
const showLeftEllipsis = left > 2;
const showRightEllipsis = right < total - 1;
const pages = [1];
/* Page numbers with '…' markers between them — a real mixed array, which is
why the renderer tests `entry === '…'` before treating it as a number.
Inference took the seed `[1]` as `number[]` and rejected the markers. */
const pages: (number | string)[] = [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('…');
@@ -47,7 +51,7 @@ export function Pagination({
onPageSizeChange,
pageSizeOptions = [10, 25, 50, 100],
className,
}) {
}: DsProps) {
if (pageCount <= 1 && !onPageSizeChange) return null;
const from = totalItems != null && pageSize ? (page - 1) * pageSize + 1 : null;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -45,7 +46,7 @@ export function ProgressBar({
valueSuffix = '',
className,
trackClassName,
}) {
}: DsProps) {
const pct = clamp((Number(value) / max) * 100);
const resolvedTone = tone === 'score' ? toneForScore(pct) : tone;
@@ -110,7 +111,7 @@ export function ProgressRing({
/** Small text below the label. */
footnote,
className,
}) {
}: DsProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const ratio = Math.max(0, Math.min(1, (Number(value) - min) / (max - min)));
@@ -178,7 +179,7 @@ export function ProgressRing({
* at a glance without relying on colour.
*/
/** @param {any} props */
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }) {
export function ProgressSteps({ steps = [], current = 0, className = '', onStepClick = null }: DsProps) {
const normalized = steps.map((s) => (typeof s === 'string' ? { label: s } : s));
return (
@@ -243,7 +244,7 @@ export function ProgressSteps({ steps = [], current = 0, className = '', onStepC
* dimensions are listed with a weight and a value.
*/
/** @param {any} props */
export function ScoreMeter({ label, value, weight, tone = 'score', className }) {
export function ScoreMeter({ label, value, weight, tone = 'score', className }: DsProps) {
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-2">

View File

@@ -10,8 +10,7 @@ import { inputVariants } from '@/components/ui/input';
* without re-running expensive work, while the field itself stays instantly
* responsive. Pass `debounce={0}` for immediate updates.
*/
/** @type {React.ForwardRefExoticComponent<any>} */
export const SearchInput = React.forwardRef(
export const SearchInput: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
(
{
value,

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -21,7 +22,7 @@ export function SectionHeader({
onViewAll,
viewAllLabel = 'View all',
className,
}) {
}: DsProps) {
if (level === 'overline') {
return (
<div className={cn('flex items-center justify-between gap-3 mb-3', className)}>

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
@@ -10,7 +11,7 @@ import { cn } from '@/lib/utils';
* `prefers-reduced-motion` (see index.css).
*/
/** @param {any} props */
export function Skeleton({ className = '', radius = 'md', ...props }) {
export function Skeleton({ className = '', radius = 'md', ...props }: DsProps) {
return (
<div
aria-hidden="true"
@@ -33,7 +34,7 @@ export function Skeleton({ className = '', radius = 'md', ...props }) {
/** Paragraph placeholder. The last line is short, as real text is. */
/** @param {any} props */
export function SkeletonText({ lines = 3, className = '' }) {
export function SkeletonText({ lines = 3, className = '' }: DsProps) {
return (
<div className={cn('space-y-2', className)} role="status" aria-label="Loading content">
{Array.from({ length: lines }).map((_, i) => (
@@ -48,7 +49,7 @@ export function SkeletonText({ lines = 3, className = '' }) {
/** Matches KpiCard: icon tile, big number, caption. */
/** @param {any} props */
export function SkeletonKpi({ className = '' }) {
export function SkeletonKpi({ className = '' }: DsProps) {
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" />
@@ -60,7 +61,7 @@ export function SkeletonKpi({ className = '' }) {
/** Matches a list row: avatar, two lines of text, trailing value. */
/** @param {any} props */
export function SkeletonRow({ className = '' }) {
export function SkeletonRow({ className = '' }: DsProps) {
return (
<div className={cn('flex items-center gap-3 py-3', className)}>
<Skeleton className="w-9 h-9" radius="full" />
@@ -75,7 +76,7 @@ export function SkeletonRow({ className = '' }) {
/** Matches a card in a grid: title, meta, stat row. */
/** @param {any} props */
export function SkeletonCard({ className = '' }) {
export function SkeletonCard({ className = '' }: DsProps) {
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">
@@ -100,7 +101,7 @@ export function SkeletonCard({ className = '' }) {
/** Matches DataTable: header row plus n body rows. */
/** @param {any} props */
export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: DsProps) {
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">
@@ -121,7 +122,7 @@ export function SkeletonTable({ rows = 5, columns = 4, className = '' }) {
/** Matches ChartContainer: title, legend, plot area. */
/** @param {any} props */
export function SkeletonChart({ className = '', height = 240 }) {
export function SkeletonChart({ className = '', height = 240 }: DsProps) {
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" />
@@ -137,7 +138,7 @@ export function SkeletonChart({ className = '', height = 240 }) {
/** n SkeletonRows inside a surface — the common "loading a list" case. */
/** @param {any} props */
export function SkeletonList({ rows = 5, className = '' }) {
export function SkeletonList({ rows = 5, className = '' }: DsProps) {
return (
<div className={cn('divide-y divide-border', className)} role="status" aria-label="Loading list">
{Array.from({ length: rows }).map((_, i) => (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
@@ -78,7 +79,7 @@ const DOT_COLOR = {
* much work; it also gives colour-blind users a second cue alongside the label.
*/
/** @param {any} props */
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }) {
export function StatusBadge({ status, label, tone, dot = false, size = 'default', className, ...props }: DsProps) {
const entry = STATUS_MAP[status] || {};
const resolvedTone = tone || entry.tone || 'neutral';
const resolvedLabel = label || entry.label || humanize(status);

View File

@@ -55,8 +55,7 @@ const surfaceVariants = cva(
}
);
/** @type {React.ForwardRefExoticComponent<any>} */
const Surface = React.forwardRef(
const Surface: React.ForwardRefExoticComponent<any> = React.forwardRef<any, any>(
({ className, variant, elevation, radius, padding, interactive, as, ...props }, ref) => {
const Comp = as || 'div';
return (

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
@@ -25,7 +26,7 @@ export function Tabs({
layoutGroupId,
className,
ariaLabel = 'Views',
}) {
}: DsProps) {
const normalized = tabs.map((t) => (typeof t === 'string' ? { value: t, label: t } : t));
const fallbackId = React.useId();
const groupId = layoutGroupId || fallbackId;

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { StatusBadge } from '@/components/ds/StatusBadge';
@@ -21,7 +22,7 @@ const TONE_DOT = {
* timeline can be nested inside a scrolling card without the rail detaching.
*/
/** @param {any} props */
export function Timeline({ items = [], className = '', compact = false }) {
export function Timeline({ items = [], className = '', compact = false }: DsProps) {
return (
<ol className={cn('relative', className)}>
{items.map((item, i) => {

View File

@@ -1,3 +1,4 @@
import type { DsProps } from './props';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
@@ -20,7 +21,7 @@ export function Toggle({
align = 'end',
className,
id: providedId,
}) {
}: DsProps) {
const reactId = React.useId();
const id = providedId || reactId;
const descriptionId = description ? `${id}-description` : undefined;
@@ -107,7 +108,7 @@ export function SegmentedToggle({
variant = 'brand',
className = '',
ariaLabel = '',
}) {
}: DsProps) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;

View File

@@ -0,0 +1,22 @@
/**
* The props bag a design-system component accepts.
*
* `any`-valued and open, and that is a faithful description rather than a
* retreat. These components carried no PropTypes and no validation, so in the
* JavaScript every prop was optional and every extra prop was passed through to
* the underlying element. TypeScript infers a destructured parameter without a
* default as REQUIRED, which invents a rule the component never had and
* rejects call sites that have always worked — 260 of them across pages this
* phase does not touch.
*
* The prop NAMES are not lost: every component still destructures them by name
* in its own signature, which is where a reader looks anyway. What is deferred
* is per-prop types, and the place to add those is each component's own
* interface, one at a time, with its call sites checked — not a sweep that
* guesses ninety-five of them at once.
*
* This alias exists so that decision is recorded once and is greppable when
* someone comes to tighten it, rather than being thirty separate `: any`s with
* no explanation between them.
*/
export type DsProps = Record<string, any>;

View File

@@ -1,4 +1,5 @@
import { toast as hotToast } from 'react-hot-toast';
import type { ToastOptions } from 'react-hot-toast';
/**
* Toast — transient confirmation of a user action.
@@ -27,8 +28,12 @@ const BASE_STYLE = {
const DURATIONS = { success: 2600, error: 4200, info: 3000, loading: Infinity };
const withDefaults = (options = {}) => ({
position: /** @type {import('react-hot-toast').ToastPosition} */ ('bottom-center'),
/* `ToastOptions` is react-hot-toast's own type, so `position` narrows to its
`ToastPosition` union rather than widening to `string` — which is what made
every call below unassignable. `Partial` because the callers pass a subset,
including none at all. */
const withDefaults = (options: Partial<ToastOptions> = {}): Partial<ToastOptions> => ({
position: 'bottom-center',
...options,
style: { ...BASE_STYLE, ...(options.style || {}) },
});