website refactor

This commit is contained in:
2026-01-18 17:55:04 +01:00
parent 489deb2991
commit 9ffe47da37
75 changed files with 1596 additions and 1259 deletions

View File

@@ -8,7 +8,7 @@ import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import {
Award,

View File

@@ -20,7 +20,7 @@ import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemp
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';

View File

@@ -14,7 +14,7 @@ import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { useMemo, useState } from 'react';

View File

@@ -38,13 +38,13 @@ import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
import { routes } from '@/lib/routing/RouteConfig';
import { GridItem } from '@/ui/GridItem';
import { GridItem } from '@/ui/primitives/GridItem';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Link as UILink } from '@/ui/Link';
import { Box } from '@/ui/primitives/Box';
import { Grid } from '@/ui/primitives/Grid';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
type PenaltyUiConfig = {

View File

@@ -7,7 +7,7 @@ import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import {
Download

View File

@@ -8,7 +8,7 @@ import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import {
AlertCircle,

View File

@@ -2,7 +2,7 @@
import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
import { SponsorBillingTemplate } from "@/templates/SponsorBillingTemplate";
import { Box } from "@/ui/Box";
import { Box } from "@/ui/primitives/Box";
import { Text } from "@/ui/Text";
import { Button } from "@/ui/Button";
import { DollarSign, AlertTriangle, Calendar, TrendingUp } from "lucide-react";

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
import { SponsorCampaignsTemplate, SponsorshipType } from "@/templates/SponsorCampaignsTemplate";
import { Box } from "@/ui/Box";
import { Box } from "@/ui/primitives/Box";
import { Text } from "@/ui/Text";
import { Button } from "@/ui/Button";

View File

@@ -10,7 +10,7 @@ import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { motion, useReducedMotion } from 'framer-motion';
import {

View File

@@ -1,9 +1,9 @@
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';

View File

@@ -1,4 +1,4 @@
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';

View File

@@ -3,8 +3,8 @@ import { Card } from '@/ui/Card';
import { GoalCard } from '@/ui/GoalCard';
import { Heading } from '@/ui/Heading';
import { MilestoneItem } from '@/components/achievements/MilestoneItem';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Grid } from '@/ui/primitives/Grid';
interface Achievement {
id: string;

View File

@@ -3,8 +3,8 @@
import React from 'react';
import { Panel } from '@/ui/Panel';
import { Glow } from '@/ui/Glow';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Grid } from '@/ui/primitives/Grid';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Section } from '@/ui/Section';

View File

@@ -8,8 +8,8 @@ import { DiscordIcon } from '@/ui/icons/DiscordIcon';
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/primitives/Stack';
import { Grid } from '@/ui/primitives/Grid';
import { Card } from '@/ui/Card';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';

View File

@@ -4,8 +4,8 @@ import React from 'react';
import { MetricCard } from '@/ui/MetricCard';
import { Activity, Users, Trophy, Calendar } from 'lucide-react';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/primitives/Grid';
import { Stack } from '@/ui/primitives/Stack';
/**
* HomeStatsStrip - A thin strip showing some status or quick info.

View File

@@ -1,10 +1,10 @@
'use client';
import { routes } from '@/lib/routing/RouteConfig';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Heading } from '@/ui/Heading';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';

View File

@@ -2,7 +2,7 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';

View File

@@ -20,12 +20,12 @@ import {
} from 'lucide-react';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel;

View File

@@ -4,7 +4,7 @@ import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';

View File

@@ -1,13 +1,13 @@
import { ArrowRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeagueLogo } from './LeagueLogo';
import { Link } from '@/ui/Link';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
interface LeagueSummaryCardProps {
id: string;

View File

@@ -5,7 +5,7 @@ import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { Card } from "@/ui/Card";
import { Stack } from "@/ui/Stack";
import { Stack } from "@/ui/primitives/Stack";
import { Text } from "@/ui/Text";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";

View File

@@ -1,7 +1,7 @@
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { Stack } from "@/ui/Stack";
import { Stack } from "@/ui/primitives/Stack";
import { Card } from "@/ui/Card";
import { ProtestListItem } from "./ProtestListItem";
import { Text } from "@/ui/Text";

View File

@@ -7,13 +7,13 @@ import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import { Modal } from "@/ui/Modal";
import { Button } from "@/ui/Button";
import { Card } from "@/ui/Card";
import { Stack } from "@/ui/Stack";
import { Stack } from "@/ui/primitives/Stack";
import { Text } from "@/ui/Text";
import { Heading } from "@/ui/Heading";
import { Icon } from "@/ui/Icon";
import { TextArea } from "@/ui/TextArea";
import { Input } from "@/ui/Input";
import { Grid } from "@/ui/Grid";
import { Grid } from "@/ui/primitives/Grid";
import {
AlertCircle,
Video,

View File

@@ -3,11 +3,11 @@
import React from 'react';
import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Badge } from '@/ui/Badge';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Icon } from '@/ui/Icon';
interface Race {

View File

@@ -1,4 +1,4 @@
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';

View File

@@ -1,5 +1,5 @@
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';

View File

@@ -9,11 +9,11 @@ import { AdminDashboardViewData } from '@/lib/view-data/AdminDashboardViewData';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { Icon } from '@/ui/Icon';
import { Box } from '@/ui/primitives/Box';
import { QuickActionLink } from '@/ui/QuickActionLink';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { StatusBadge } from '@/ui/StatusBadge';
import { Text } from '@/ui/Text';
import {

View File

@@ -10,10 +10,10 @@ import { routes } from '@/lib/routing/RouteConfig';
import type { DashboardViewData } from '@/lib/view-data/DashboardViewData';
import { Avatar } from '@/ui/Avatar';
import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Grid } from '@/ui/primitives/Grid';
import { IconButton } from '@/ui/IconButton';
import { Box } from '@/ui/primitives/Box';
import { Stack } from '@/ui/Stack';
import { Stack } from '@/ui/primitives/Stack';
import { Text } from '@/ui/Text';
import { Bell, Calendar, LayoutDashboard, Search, Settings, Trophy, Users } from 'lucide-react';
import { useRouter } from 'next/navigation';

View File

@@ -4,7 +4,7 @@ import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeader
import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { Container } from '@/ui/Container';
import { GridItem } from '@/ui/GridItem';
import { GridItem } from '@/ui/primitives/GridItem';
import { PageHero } from '@/ui/PageHero';
import { Grid } from '@/ui/primitives/Grid';
import { Trophy, Users } from 'lucide-react';

View File

@@ -1,66 +1,67 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Link } from './Link';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Link } from './Link';
interface ActivityItemProps {
headline: string;
title?: string;
description?: string;
timeAgo?: string;
color?: string;
headline?: string;
body?: string;
formattedTime: string;
formattedTime?: string;
ctaHref?: string;
ctaLabel?: string;
typeColor?: string;
}
export function ActivityItem({
export function ActivityItem({
title,
description,
timeAgo,
color = 'bg-primary-blue',
headline,
body,
formattedTime,
ctaHref,
ctaLabel,
typeColor,
ctaLabel
}: ActivityItemProps) {
return (
<Surface
variant="muted"
padding={3}
rounded="lg"
style={{ display: 'flex', alignItems: 'start', gap: '0.75rem' }}
display="flex"
alignItems="start"
gap={3}
p={4}
>
{typeColor && (
<Box
style={{
width: '0.5rem',
height: '0.5rem',
borderRadius: '9999px',
marginTop: '0.5rem',
backgroundColor: typeColor,
flexShrink: 0,
}}
/>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Box
w="2"
h="2"
mt={1.5}
rounded="full"
bg={color}
flexShrink={0}
/>
<Box flex={1} minWidth={0}>
<Text color="text-white" weight="medium" block>
{headline}
{title || headline}
</Text>
{body && (
<Text size="sm" color="text-gray-400" block mt={1}>
{body}
</Text>
<Text size="sm" color="text-gray-400" block mt={0.5}>
{description || body}
</Text>
<Text size="xs" color="text-gray-500" block mt={2}>
{timeAgo || formattedTime}
</Text>
{ctaHref && ctaLabel && (
<Box mt={3}>
<Link href={ctaHref} size="xs" variant="primary">
{ctaLabel}
</Link>
</Box>
)}
<Text size="xs" color="text-gray-500" block mt={1}>
{formattedTime}
</Text>
</Box>
{ctaHref && ctaLabel && (
<Box>
<Link href={ctaHref} variant="primary">
<Text size="xs">{ctaLabel}</Text>
</Link>
</Box>
)}
</Surface>
);
}

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { Box } from './primitives/Box';
import { LoadingSpinner } from './LoadingSpinner';
import { Stack } from './primitives/Stack';
import { LoadingSpinner } from './LoadingSpinner';
import { Text } from './Text';
interface AuthLoadingProps {
@@ -11,10 +10,18 @@ interface AuthLoadingProps {
export function AuthLoading({ message = 'Authenticating...' }: AuthLoadingProps) {
return (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#0f1115' }}>
<Box
fullWidth
minHeight="100vh"
display="flex"
center
bg="bg-[#0f1115]"
>
<Stack align="center" gap={4}>
<LoadingSpinner size={12} />
<Text color="text-gray-400">{message}</Text>
<LoadingSpinner size={10} />
<Text color="text-gray-400" weight="medium">
{message}
</Text>
</Stack>
</Box>
);

View File

@@ -1,51 +1,44 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Image } from './Image';
import { User } from 'lucide-react';
import { Icon } from './Icon';
import { Surface } from './primitives/Surface';
export interface AvatarProps {
driverId?: string;
src?: string;
interface AvatarProps {
src?: string | null;
alt: string;
size?: number;
className?: string;
border?: boolean;
}
export function Avatar({
driverId,
src,
alt,
size = 40,
className = '',
border = true,
}: AvatarProps) {
const avatarSrc = src || (driverId ? `/media/avatar/${driverId}` : undefined);
export function Avatar({ src, alt, size = 40, className = '' }: AvatarProps) {
return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
<Surface
variant="muted"
rounded="full"
overflow="hidden"
bg="bg-charcoal-outline/20"
border={border}
border
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
w={`${size}px`}
h={`${size}px`}
flexShrink={0}
overflow="hidden"
>
{avatarSrc ? (
{src ? (
<Image
src={avatarSrc}
src={src}
alt={alt}
className="w-full h-full object-cover"
fullWidth
fullHeight
className="object-cover"
fallbackSrc="/default-avatar.png"
/>
) : (
<Icon icon={User} size={size > 32 ? 5 : 4} color="text-gray-500" />
<Box fullWidth fullHeight bg="bg-charcoal-outline" display="flex" center>
<span className="text-gray-400 font-bold" style={{ fontSize: size * 0.4 }}>
{alt.charAt(0).toUpperCase()}
</span>
</Box>
)}
</Box>
</Surface>
);
}

View File

@@ -1,22 +1,16 @@
import React, { ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Box, BoxProps } from './primitives/Box';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface BadgeProps {
interface BadgeProps extends Omit<BoxProps<'div'>, 'children'> {
children: ReactNode;
className?: string;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
size?: 'xs' | 'sm' | 'md';
icon?: LucideIcon;
style?: React.CSSProperties;
bg?: string;
color?: string;
borderColor?: string;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
}
export function Badge({ children, className = '', variant = 'default', size = 'sm', icon, style, bg, color, borderColor, rounded = 'none' }: BadgeProps) {
export function Badge({ children, className = '', variant = 'default', size = 'sm', icon, rounded = 'none', ...props }: BadgeProps) {
const baseClasses = 'flex items-center gap-1.5 border font-bold uppercase tracking-widest';
const sizeClasses = {
@@ -47,16 +41,13 @@ export function Badge({ children, className = '', variant = 'default', size = 's
const classes = [
baseClasses,
sizeClasses[size],
roundedClasses[rounded],
!bg && !color && !borderColor ? variantClasses[variant] : '',
bg,
color,
borderColor,
typeof rounded === 'string' && roundedClasses[rounded as keyof typeof roundedClasses] ? roundedClasses[rounded as keyof typeof roundedClasses] : '',
!props.bg && !props.color && !props.borderColor ? variantClasses[variant] : '',
className
].filter(Boolean).join(' ');
return (
<Box className={classes} style={style}>
<Box className={classes} {...props}>
{icon && <Icon icon={icon} size={3} />}
{children}
</Box>

View File

@@ -1,64 +1,63 @@
import { Badge } from './Badge';
import React from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
interface Tab {
id: string;
label: string;
count?: number;
countVariant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
icon?: React.ReactNode;
}
interface BorderTabsProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
className?: string;
}
export function BorderTabs({ tabs, activeTab, onTabChange }: BorderTabsProps) {
export function BorderTabs({ tabs, activeTab, onTabChange, className = '' }: BorderTabsProps) {
return (
<Box borderBottom borderColor="border-charcoal-outline">
<Box display="flex" gap={4}>
<Box borderBottom borderColor="border-border-gray/50" className={className}>
<Stack direction="row" gap={8}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Box
<Surface
key={tab.id}
as="button"
type="button"
onClick={() => onTabChange(tab.id)}
pb={3}
variant="ghost"
px={1}
cursor="pointer"
transition
borderBottom={isActive}
py={4}
position="relative"
borderColor={isActive ? 'border-primary-blue' : ''}
style={{
borderBottomWidth: isActive ? '2px' : '0',
marginBottom: '-1px'
}}
borderBottom={isActive}
borderWidth={isActive ? '2px' : '0'}
mb="-1px"
transition="all 0.2s"
group
>
<Box display="flex" alignItems="center" gap={2}>
<Stack direction="row" align="center" gap={2}>
{tab.icon && (
<Box color={isActive ? 'text-primary-blue' : 'text-gray-400'} groupHoverTextColor={!isActive ? 'white' : undefined}>
{tab.icon}
</Box>
)}
<Text
size="sm"
weight="medium"
color={isActive ? 'text-primary-blue' : 'text-gray-400'}
className={!isActive ? 'hover:text-white' : ''}
color={isActive ? 'text-primary-blue' : 'text-gray-400'}
groupHoverTextColor={!isActive ? 'white' : undefined}
>
{tab.label}
</Text>
{tab.count !== undefined && tab.count > 0 && (
<Badge variant={tab.countVariant || 'warning'}>
{tab.count}
</Badge>
)}
</Box>
</Box>
</Stack>
</Surface>
);
})}
</Box>
</Stack>
</Box>
);
}

View File

@@ -4,7 +4,7 @@ import { Box, BoxProps } from './primitives/Box';
import { Loader2 } from 'lucide-react';
import { Icon } from './Icon';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit' | 'role' | 'translate' | 'onScroll' | 'draggable' | 'onChange' | 'onMouseDown' | 'onMouseUp' | 'onMouseMove' | 'value' | 'onBlur' | 'onKeyDown'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
className?: string;
@@ -19,6 +19,8 @@ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as'
href?: string;
target?: string;
rel?: string;
fontSize?: string;
backgroundColor?: string;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
@@ -36,6 +38,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
href,
target,
rel,
fontSize,
backgroundColor,
...props
}, ref) => {
const baseClasses = 'inline-flex items-center justify-center rounded-none transition-all duration-150 ease-smooth focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 active:opacity-80 uppercase tracking-widest font-bold';
@@ -83,6 +87,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
target={target}
rel={rel}
className={classes}
fontSize={fontSize}
backgroundColor={backgroundColor}
{...props}
>
{content}
@@ -98,6 +104,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className={classes}
onClick={onClick}
disabled={disabled || isLoading}
fontSize={fontSize}
backgroundColor={backgroundColor}
{...props}
>
{content}

View File

@@ -1,26 +1,10 @@
import React, { ReactNode, MouseEventHandler } from 'react';
import { Box, BoxProps } from './primitives/Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface ResponsiveSpacing {
base?: Spacing;
md?: Spacing;
lg?: Spacing;
}
interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'className' | 'onClick'> {
export interface CardProps extends Omit<BoxProps<'div'>, 'children' | 'onClick'> {
children: ReactNode;
className?: string;
onClick?: MouseEventHandler<HTMLDivElement>;
variant?: 'default' | 'outline' | 'ghost';
p?: Spacing | ResponsiveSpacing;
px?: Spacing | ResponsiveSpacing;
py?: Spacing | ResponsiveSpacing;
pt?: Spacing | ResponsiveSpacing;
pb?: Spacing | ResponsiveSpacing;
pl?: Spacing | ResponsiveSpacing;
pr?: Spacing | ResponsiveSpacing;
variant?: 'default' | 'outline' | 'ghost' | 'muted' | 'dark' | 'glass';
}
export function Card({
@@ -35,7 +19,10 @@ export function Card({
const variantClasses = {
default: 'bg-panel-gray border border-border-gray shadow-card',
outline: 'bg-transparent border border-border-gray',
ghost: 'bg-transparent border-none'
ghost: 'bg-transparent border-none',
muted: 'bg-panel-gray/40 border border-border-gray',
dark: 'bg-graphite-black border border-border-gray',
glass: 'bg-graphite-black/60 backdrop-blur-md border border-border-gray'
};
const classes = [

View File

@@ -1,35 +1,49 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
import { ProgressBar } from './ProgressBar';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface CategoryDistributionCardProps {
label: string;
count: number;
percentage: number;
icon: LucideIcon;
color: string;
bgColor: string;
borderColor: string;
progressColor: string;
}
export function CategoryDistributionCard({
label,
count,
percentage,
icon,
color,
bgColor,
borderColor,
progressColor,
}: CategoryDistributionCardProps) {
return (
<Box p={4} rounded="xl" className={`${bgColor} border ${borderColor}`}>
<Box p={4} rounded="xl" bg={bgColor} border borderColor={borderColor}>
<Box display="flex" alignItems="center" justifyContent="between" mb={3}>
<Text size="2xl" weight="bold" className={color}>{count}</Text>
<Text size="2xl" weight="bold" color={color}>{count}</Text>
<Box p={2} rounded="lg" bg="bg-white/5">
<Icon icon={icon} size={5} color={color} />
</Box>
</Box>
<Text color="text-white" weight="medium" block mb={1}>{label}</Text>
<ProgressBar value={percentage} max={100} color={progressColor} bg="bg-deep-graphite/50" />
<Text size="xs" color="text-gray-500" block mt={1}>{percentage}% of drivers</Text>
<Text size="sm" weight="medium" color="text-white" block mb={1}>
{label}
</Text>
<Box w="full" h="1.5" bg="bg-white/5" rounded="full" overflow="hidden">
<Box
h="full"
bg={color.replace('text-', 'bg-')}
style={{ width: `${percentage}%` }}
/>
</Box>
<Text size="xs" color="text-gray-500" mt={2}>
{percentage.toFixed(1)}% of total
</Text>
</Box>
);
}

View File

@@ -1,5 +1,3 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
@@ -26,7 +24,8 @@ export function Checkbox({ label, checked, onChange, disabled }: CheckboxProps)
border
borderColor="border-charcoal-outline"
rounded="sm"
className="text-primary-blue focus:ring-primary-blue"
ring="primary-blue"
color="text-primary-blue"
/>
<Text size="sm" color={disabled ? 'text-gray-500' : 'text-white'}>{label}</Text>
</Box>

View File

@@ -3,7 +3,7 @@ import { Box, BoxProps } from './primitives/Box';
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface ContainerProps extends BoxProps<'div'> {
interface ContainerProps extends Omit<BoxProps<'div'>, 'size' | 'padding'> {
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
padding?: boolean;

View File

@@ -1,142 +1,146 @@
import React, { ReactNode } from 'react';
import React from 'react';
import { Box } from './primitives/Box';
import { Heading } from './Heading';
import { Image } from './Image';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
import { Glow } from './Glow';
import { Heading } from './Heading';
import { Avatar } from './Avatar';
import { Badge } from './Badge';
import { Trophy, Flag, Users, Star } from 'lucide-react';
import { Icon } from './Icon';
interface DashboardHeroProps {
driverName: string;
avatarUrl: string;
country: string;
rating: string | number;
rank: string | number;
totalRaces: string | number;
actions?: ReactNode;
stats?: ReactNode;
avatarUrl?: string | null;
rating: number;
rank: number;
totalRaces: number;
winRate: number;
className?: string;
}
/**
* DashboardHero
*
* Redesigned for "Precision Racing Minimal" theme.
* Uses subtle accent glows and crisp separators.
*/
export function DashboardHero({
driverName,
avatarUrl,
country,
rating,
rank,
totalRaces,
actions,
stats,
winRate,
className = '',
}: DashboardHeroProps) {
return (
<Box
as="section"
position="relative"
className={`bg-[#0C0D0F] border-b border-[#23272B] overflow-hidden ${className}`}
<Box
position="relative"
bg="bg-[#0C0D0F]"
borderBottom
borderColor="border-[#23272B]"
overflow="hidden"
className={className}
>
{/* Subtle Accent Glow */}
<Glow
position="top-right"
color="primary"
opacity={0.1}
size="xl"
{/* Background Glow */}
<Box
position="absolute"
top={-100}
right={-100}
w="500px"
h="500px"
bg="bg-primary-blue/10"
rounded="full"
blur="3xl"
/>
<Box
maxWidth="80rem"
mx="auto"
px={6}
py={8}
position="relative"
zIndex={1}
>
<Box display="flex" flexDirection="col" gap={8}>
<Box display="flex" align="center" justify="between" wrap gap={6}>
{/* Driver Identity */}
<Box display="flex" align="center" gap={6}>
<Box position="relative">
<Box
w="24"
h="24"
className="border border-[#23272B] p-1 bg-[#141619]"
>
<Image
src={avatarUrl}
alt={driverName}
width={96}
height={96}
className="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-300"
/>
</Box>
<Box
position="absolute"
bottom="-1"
right="-1"
w="4"
h="4"
className="bg-[#4ED4E0] border-2 border-[#0C0D0F]"
/>
</Box>
<Box>
<Box display="flex" align="center" gap={3} mb={1}>
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
Driver Profile
</Text>
<Text size="xs" color="text-gray-600">
/
</Text>
<Text size="xs" color="text-gray-400">
{country}
</Text>
</Box>
<Heading level={1} className="text-3xl md:text-4xl font-black uppercase tracking-tighter mb-2">
{driverName}
</Heading>
<Box display="flex" align="center" gap={4}>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Rating</Text>
<Text size="sm" weight="bold" className="text-[#4ED4E0] font-mono">{rating}</Text>
</Box>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
<Text size="sm" weight="bold" className="text-[#FFBE4D] font-mono">#{rank}</Text>
</Box>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
<Text size="sm" weight="bold" className="text-gray-300 font-mono">{totalRaces}</Text>
</Box>
</Box>
</Box>
<Box p={{ base: 6, md: 10 }} position="relative" zIndex={10}>
<Stack direction={{ base: 'col', md: 'row' }} align="center" gap={8}>
{/* Avatar Section */}
<Box position="relative">
<Box
p={1}
rounded="2xl"
bg="bg-[#141619]"
border
borderColor="border-[#23272B]"
>
<Avatar
src={avatarUrl}
alt={driverName}
size={120}
className="rounded-xl"
/>
</Box>
<Box
position="absolute"
bottom={-2}
right={-2}
w="10"
h="10"
rounded="xl"
bg="bg-[#4ED4E0]"
borderWidth="2px"
borderStyle="solid"
borderColor="border-[#0C0D0F]"
display="flex"
center
>
<Icon icon={Star} size={5} color="#0C0D0F" />
</Box>
{/* Actions */}
{actions && (
<Box display="flex" gap={3}>
{actions}
</Box>
)}
</Box>
{/* Stats Grid */}
{stats && (
<Box
display="grid"
gridCols={2}
responsiveGridCols={{ md: 4 }}
gap={4}
className="border-t border-[#23272B]/50 pt-6"
>
{stats}
{/* Info Section */}
<Stack flex={1} align={{ base: 'center', md: 'start' }} gap={4}>
<Box>
<Heading level={1} uppercase letterSpacing="tighter" mb={2}>
{driverName}
</Heading>
<Stack direction="row" gap={4}>
<Stack gap={0.5}>
<Text size="xs" color="text-gray-500" uppercase>Rating</Text>
<Text size="sm" weight="bold" color="text-[#4ED4E0]" font="mono">{rating}</Text>
</Stack>
<Stack gap={0.5}>
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
<Text size="sm" weight="bold" color="text-[#FFBE4D]" font="mono">#{rank}</Text>
</Stack>
<Stack gap={0.5}>
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
<Text size="sm" weight="bold" color="text-gray-300" font="mono">{totalRaces}</Text>
</Stack>
</Stack>
</Box>
)}
</Box>
<Stack direction="row" gap={3} wrap>
<Badge variant="primary" rounded="lg" icon={Trophy}>
{winRate}% Win Rate
</Badge>
<Badge variant="info" rounded="lg" icon={Flag}>
Pro License
</Badge>
<Badge variant="default" rounded="lg" icon={Users}>
Team Redline
</Badge>
</Stack>
</Stack>
{/* Quick Stats */}
<Stack
direction="row"
gap={4}
p={6}
bg="bg-white/5"
rounded="2xl"
border
borderColor="border-white/10"
className="backdrop-blur-md"
>
<Stack align="center" px={4}>
<Text size="2xl" weight="bold" color="text-white">12</Text>
<Text size="xs" color="text-gray-500" uppercase>Podiums</Text>
</Stack>
<Box w="1px" h="10" bg="bg-white/10" />
<Stack align="center" px={4}>
<Text size="2xl" weight="bold" color="text-white">4</Text>
<Text size="xs" color="text-gray-500" uppercase>Wins</Text>
</Stack>
</Stack>
</Stack>
</Box>
</Box>
);

View File

@@ -1,36 +1,63 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Surface } from './primitives/Surface';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { AlertCircle, XCircle, Info, AlertTriangle } from 'lucide-react';
export interface ErrorBannerProps {
message: string;
interface ErrorBannerProps {
title?: string;
variant?: 'error' | 'warning' | 'info';
message: string;
variant?: 'error' | 'warning' | 'info' | 'success';
}
export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) {
const variantColors = {
error: { bg: 'rgba(239, 68, 68, 0.1)', border: '#ef4444', text: '#ef4444' },
warning: { bg: 'rgba(245, 158, 11, 0.1)', border: '#f59e0b', text: '#fcd34d' },
info: { bg: 'rgba(59, 130, 246, 0.1)', border: '#3b82f6', text: '#3b82f6' },
export function ErrorBanner({ title, message, variant = 'error' }: ErrorBannerProps) {
const configs = {
error: {
bg: 'rgba(239, 68, 68, 0.1)',
border: 'rgba(239, 68, 68, 0.2)',
text: 'rgb(248, 113, 113)',
icon: XCircle
},
warning: {
bg: 'rgba(245, 158, 11, 0.1)',
border: 'rgba(245, 158, 11, 0.2)',
text: 'rgb(251, 191, 36)',
icon: AlertTriangle
},
info: {
bg: 'rgba(59, 130, 246, 0.1)',
border: 'rgba(59, 130, 246, 0.2)',
text: 'rgb(96, 165, 250)',
icon: Info
},
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.2)',
text: 'rgb(52, 211, 153)',
icon: AlertCircle
}
};
const colors = variantColors[variant];
const colors = configs[variant];
return (
<Surface
variant="muted"
rounded="lg"
rounded="xl"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
p={4}
backgroundColor={colors.bg}
borderColor={colors.border}
>
<Box style={{ flex: 1 }}>
{title && <Text weight="medium" style={{ color: colors.text }} block mb={1}>{title}</Text>}
<Text size="sm" style={{ color: colors.text, opacity: 0.9 }} block>{message}</Text>
</Box>
<Stack direction="row" align="start" gap={3}>
<Icon icon={colors.icon} size={5} color={colors.text} />
<Box flex={1}>
{title && <Text weight="medium" color={colors.text} block mb={1}>{title}</Text>}
<Text size="sm" color={colors.text} opacity={0.9} block>{message}</Text>
</Box>
</Stack>
</Surface>
);
}

View File

@@ -20,7 +20,11 @@ export function FormSection({ children, title }: FormSectionProps) {
size="xs"
weight="bold"
color="text-gray-500"
className="uppercase tracking-widest border-b border-border-gray pb-1"
uppercase
letterSpacing="widest"
borderBottom
borderColor="border-border-gray"
pb={1}
>
{title}
</Text>

View File

@@ -1,6 +1,6 @@
import React, { ReactNode, ElementType } from 'react';
import { Stack } from './primitives/Stack';
import { Box, BoxProps } from './primitives/Box';
import { Box, BoxProps, ResponsiveValue } from './primitives/Box';
interface ResponsiveFontSize {
base?: string;
@@ -18,11 +18,13 @@ interface HeadingProps extends Omit<BoxProps<'h1'>, 'children' | 'as' | 'fontSiz
id?: string;
groupHoverColor?: string;
truncate?: boolean;
uppercase?: boolean;
fontSize?: string | ResponsiveFontSize;
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
letterSpacing?: string;
}
export function Heading({ level, children, icon, groupHoverColor, truncate, fontSize, weight, ...props }: HeadingProps) {
export function Heading({ level, children, icon, groupHoverColor, truncate, uppercase, fontSize, weight, letterSpacing, ...props }: HeadingProps) {
const Tag = `h${level}` as ElementType;
const levelClasses = {
@@ -34,7 +36,7 @@ export function Heading({ level, children, icon, groupHoverColor, truncate, font
6: 'text-xs font-bold text-white tracking-tight uppercase tracking-widest',
};
const weightClasses = {
const weightClasses: Record<string, string> = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
@@ -67,14 +69,24 @@ export function Heading({ level, children, icon, groupHoverColor, truncate, font
const classes = [
levelClasses[level],
getFontSizeClasses(fontSize),
weight ? weightClasses[weight] : '',
weight && weightClasses[weight as keyof typeof weightClasses] ? weightClasses[weight as keyof typeof weightClasses] : '',
letterSpacing ? `tracking-${letterSpacing}` : '',
uppercase ? 'uppercase' : '',
groupHoverColor ? `group-hover:text-${groupHoverColor}` : '',
truncate ? 'truncate' : '',
props.className
].filter(Boolean).join(' ');
return (
<Box as={Tag} {...props} className={classes}>
<Box
as={Tag}
{...props}
className={classes}
style={{
...(weight && !weightClasses[weight as keyof typeof weightClasses] ? { fontWeight: weight } : {}),
...(props.style || {})
}}
>
{content}
</Box>
);

View File

@@ -1,52 +1,46 @@
import { ReactNode } from 'react';
import React from 'react';
import { Box } from './primitives/Box';
import { Card } from './Card';
import { Stack } from './primitives/Stack';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface HorizontalStatCardProps {
label: string;
value: string | number;
subValue?: string;
icon: ReactNode;
icon: LucideIcon;
iconColor?: string;
iconBgColor?: string;
}
export function HorizontalStatCard({
label,
value,
subValue,
icon,
iconBgColor,
iconColor = 'text-primary-blue',
iconBgColor = 'rgba(59, 130, 246, 0.1)',
}: HorizontalStatCardProps) {
return (
<Card>
<Stack direction="row" align="center" gap={3}>
<Surface
variant="muted"
rounded="full"
padding={3}
style={{ backgroundColor: iconBgColor }}
<Surface variant="muted" rounded="xl" border p={4}>
<Stack direction="row" align="center" gap={4}>
<Surface
variant="muted"
rounded="lg"
p={3}
backgroundColor={iconBgColor}
>
{icon}
<Icon icon={icon} size={5} color={iconColor} />
</Surface>
<Box>
<Text size="xs" color="text-gray-400" block mb={1}>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="wider" block>
{label}
</Text>
<Text size="2xl" weight="bold" color="text-white" block>
<Text size="xl" weight="bold" color="text-white" block>
{value}
</Text>
{subValue && (
<Text size="sm" color="text-gray-400">
{subValue}
</Text>
)}
</Box>
</Stack>
</Card>
</Surface>
);
}

View File

@@ -2,14 +2,29 @@ import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Box, BoxProps } from './primitives/Box';
interface IconProps extends Omit<BoxProps<'svg'>, 'children' | 'as'> {
icon: LucideIcon;
export interface IconProps extends Omit<BoxProps<'div'>, 'children'> {
icon: LucideIcon | React.ReactNode;
size?: number | string;
color?: string;
strokeWidth?: number;
animate?: string;
transition?: boolean;
groupHoverTextColor?: string;
groupHoverScale?: boolean;
}
export function Icon({ icon: LucideIcon, size = 4, color, className = '', style, ...props }: IconProps) {
export function Icon({
icon: IconProp,
size = 4,
color,
className = '',
style,
animate,
transition,
groupHoverTextColor,
groupHoverScale,
...props
}: IconProps) {
const sizeMap: Record<string | number, string> = {
3: 'w-3 h-3',
3.5: 'w-3.5 h-3.5',
@@ -31,13 +46,35 @@ export function Icon({ icon: LucideIcon, size = 4, color, className = '', style,
const combinedStyle = color && !isTailwindColor ? { color, ...style } : style;
const boxColor = isTailwindColor ? color : undefined;
const classes = [
sizeClass,
animate === 'spin' ? 'animate-spin' : '',
transition ? 'transition-all duration-150' : '',
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
groupHoverScale ? 'group-hover:scale-110 transition-transform' : '',
className
].filter(Boolean).join(' ');
const renderIcon = () => {
if (!IconProp) return null;
if (typeof IconProp === 'function' || (typeof IconProp === 'object' && 'render' in IconProp)) {
const LucideIconComponent = IconProp as LucideIcon;
return <LucideIconComponent size="100%" strokeWidth={props.strokeWidth} />;
}
return IconProp;
};
return (
<Box
as={LucideIcon}
className={`${sizeClass} ${className}`}
className={classes}
style={combinedStyle}
color={boxColor}
display="inline-flex"
alignItems="center"
justifyContent="center"
{...props}
/>
>
{renderIcon()}
</Box>
);
}

View File

@@ -1,5 +1,3 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Button } from './Button';
@@ -29,9 +27,9 @@ export function IconButton({
backgroundColor,
}: IconButtonProps) {
const sizeMap = {
sm: { btn: 'w-8 h-8 p-0', icon: 4 },
md: { btn: 'w-10 h-10 p-0', icon: 5 },
lg: { btn: 'w-12 h-12 p-0', icon: 6 },
sm: { w: '8', h: '8', icon: 4 },
md: { w: '10', h: '10', icon: 5 },
lg: { w: '12', h: '12', icon: 6 },
};
return (
@@ -40,7 +38,14 @@ export function IconButton({
onClick={onClick}
title={title}
disabled={disabled}
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0 ${className}`}
w={sizeMap[size].w}
h={sizeMap[size].h}
p={0}
rounded="full"
display="flex"
center
minHeight="0"
className={className}
backgroundColor={backgroundColor}
>
<Icon icon={icon} size={sizeMap[size].icon} color={color} />

View File

@@ -1,83 +1,75 @@
import { AlertTriangle, CheckCircle, Info, LucideIcon, XCircle } from 'lucide-react';
import React from 'react';
import { Box } from './primitives/Box';
import { Icon } from './Icon';
import { Stack } from './primitives/Stack';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
type BannerType = 'info' | 'warning' | 'success' | 'error';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { Info, AlertTriangle, AlertCircle, CheckCircle, LucideIcon } from 'lucide-react';
interface InfoBannerProps {
type?: BannerType;
title?: string;
children: React.ReactNode;
message?: string;
children?: React.ReactNode;
variant?: 'info' | 'warning' | 'error' | 'success';
type?: 'info' | 'warning' | 'error' | 'success';
icon?: LucideIcon;
}
export function InfoBanner({
type = 'info',
title,
children,
icon: CustomIcon,
}: InfoBannerProps) {
const bannerConfig: Record<BannerType, {
icon: LucideIcon;
bg: string;
border: string;
titleColor: string;
iconColor: string;
}> = {
export function InfoBanner({ title, message, children, variant = 'info', type, icon }: InfoBannerProps) {
const configs = {
info: {
icon: Info,
bg: 'rgba(38, 38, 38, 0.3)',
border: 'rgba(38, 38, 38, 0.5)',
titleColor: 'text-gray-300',
iconColor: '#9ca3af',
bg: 'rgba(59, 130, 246, 0.1)',
border: 'rgba(59, 130, 246, 0.2)',
iconColor: 'rgb(96, 165, 250)',
icon: Info
},
warning: {
icon: AlertTriangle,
bg: 'rgba(245, 158, 11, 0.1)',
border: 'rgba(245, 158, 11, 0.3)',
titleColor: 'text-warning-amber',
iconColor: '#f59e0b',
},
success: {
icon: CheckCircle,
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.3)',
titleColor: 'text-performance-green',
iconColor: '#10b981',
border: 'rgba(245, 158, 11, 0.2)',
iconColor: 'rgb(251, 191, 36)',
icon: AlertTriangle
},
error: {
icon: XCircle,
bg: 'rgba(239, 68, 68, 0.1)',
border: 'rgba(239, 68, 68, 0.3)',
titleColor: 'text-error-red',
iconColor: '#ef4444',
border: 'rgba(239, 68, 68, 0.2)',
iconColor: 'rgb(248, 113, 113)',
icon: AlertCircle
},
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.2)',
iconColor: 'rgb(52, 211, 153)',
icon: CheckCircle
}
};
const config = bannerConfig[type];
const BannerIcon = CustomIcon || config.icon;
const activeVariant = type || variant;
const config = configs[activeVariant as keyof typeof configs] || configs.info;
const BannerIcon = icon || config.icon;
return (
<Surface
variant="muted"
rounded="lg"
rounded="xl"
border
padding={4}
style={{ backgroundColor: config.bg, borderColor: config.border }}
p={4}
backgroundColor={config.bg}
borderColor={config.border}
>
<Stack direction="row" align="start" gap={3}>
<Icon icon={BannerIcon} size={5} color={config.iconColor} />
<Box style={{ flex: 1 }}>
<Box flex={1}>
{title && (
<Text weight="medium" color={config.titleColor} block mb={1}>{title}</Text>
<Text weight="medium" color="text-white" block mb={1}>
{title}
</Text>
)}
<Text size="sm" color="text-gray-400" block>{children}</Text>
{message && (
<Text size="sm" color="text-gray-300" block>
{message}
</Text>
)}
{children}
</Box>
</Stack>
</Surface>

View File

@@ -1,62 +1,63 @@
import React from 'react';
import { Surface } from './primitives/Surface';
import { Stack } from './primitives/Stack';
import { Box } from './primitives/Box';
import { Icon } from './Icon';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface InfoBoxProps {
icon: LucideIcon;
title: string;
description: string;
variant?: 'primary' | 'success' | 'warning' | 'default';
icon: LucideIcon;
variant?: 'info' | 'warning' | 'error' | 'success';
}
export function InfoBox({ icon, title, description, variant = 'default' }: InfoBoxProps) {
const variantColors = {
primary: {
export function InfoBox({ title, description, icon, variant = 'info' }: InfoBoxProps) {
const configs = {
info: {
bg: 'rgba(59, 130, 246, 0.1)',
border: '#3b82f6',
text: '#3b82f6',
icon: '#3b82f6'
},
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: '#10b981',
text: '#10b981',
icon: '#10b981'
border: 'rgba(59, 130, 246, 0.2)',
icon: 'rgb(96, 165, 250)',
text: 'text-white'
},
warning: {
bg: 'rgba(245, 158, 11, 0.1)',
border: '#f59e0b',
text: '#f59e0b',
icon: '#f59e0b'
border: 'rgba(245, 158, 11, 0.2)',
icon: 'rgb(251, 191, 36)',
text: 'text-white'
},
default: {
bg: 'rgba(38, 38, 38, 0.3)',
border: '#262626',
text: 'white',
icon: '#9ca3af'
error: {
bg: 'rgba(239, 68, 68, 0.1)',
border: 'rgba(239, 68, 68, 0.2)',
icon: 'rgb(248, 113, 113)',
text: 'text-white'
},
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.2)',
icon: 'rgb(52, 211, 153)',
text: 'text-white'
}
};
const colors = variantColors[variant];
const colors = configs[variant];
return (
<Surface
variant="muted"
rounded="xl"
border
padding={4}
style={{ backgroundColor: colors.bg, borderColor: colors.border }}
p={4}
backgroundColor={colors.bg}
borderColor={colors.border}
>
<Stack direction="row" align="start" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255, 255, 255, 0.05)' }}>
<Surface variant="muted" rounded="lg" p={2} bg="bg-white/5">
<Icon icon={icon} size={5} color={colors.icon} />
</Surface>
<Box>
<Text weight="medium" style={{ color: colors.text }} block>{title}</Text>
<Text weight="medium" color={colors.text} block>{title}</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{description}</Text>
</Box>
</Stack>

View File

@@ -1,26 +1,30 @@
import React, { forwardRef, InputHTMLAttributes } from 'react';
import { Text } from './Text';
import React, { forwardRef, ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'default' | 'error';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
icon?: ReactNode;
errorMessage?: string;
icon?: React.ReactNode;
label?: React.ReactNode;
variant?: 'default' | 'error';
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', variant = 'default', errorMessage, icon, label, ...props }, ref) => {
const baseClasses = 'px-3 py-2 border rounded-sm text-white bg-graphite-black focus:outline-none focus:border-primary-accent transition-all duration-150 ease-smooth w-full text-sm placeholder:text-gray-600';
const variantClasses = (variant === 'error' || errorMessage) ? 'border-critical-red' : 'border-border-gray';
const iconClasses = icon ? 'pl-10' : '';
const classes = `${baseClasses} ${variantClasses} ${iconClasses} ${className}`;
({ label, icon, errorMessage, variant = 'default', className = '', ...props }, ref) => {
const isError = variant === 'error' || !!errorMessage;
const baseClasses = 'w-full px-4 py-2 bg-deep-graphite border rounded-lg text-white placeholder:text-gray-500 focus:outline-none transition-all duration-150 sm:text-sm';
const variantClasses = isError
? 'border-warning-amber focus:border-warning-amber focus:ring-1 focus:ring-warning-amber'
: 'border-charcoal-outline focus:border-primary-blue focus:ring-1 focus:ring-primary-blue';
const classes = `${baseClasses} ${variantClasses} ${icon ? 'pl-11' : ''} ${className}`;
return (
<Stack gap={1.5} fullWidth>
{label && (
<Text as="label" size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-wider">
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{label}
</Text>
)}
@@ -28,20 +32,21 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{icon && (
<Box
position="absolute"
left="3"
left={0}
top="50%"
style={{ transform: 'translateY(-50%)' }}
zIndex={10}
display="flex"
center
className="text-gray-500"
translateY="-50%"
zIndex={10}
w="11"
display="flex"
center
color="text-gray-500"
>
{icon}
</Box>
)}
<input ref={ref} className={classes} {...props} />
{errorMessage && (
<Text size="xs" color="text-critical-red" block mt={1}>
<Text size="xs" color="text-warning-amber" mt={1}>
{errorMessage}
</Text>
)}

View File

@@ -1,19 +1,19 @@
import React, { ReactNode } from 'react';
import { Box, BoxProps } from './primitives/Box';
interface LinkProps extends Omit<BoxProps<'a'>, 'children' | 'className' | 'onClick'> {
export interface LinkProps extends Omit<BoxProps<'a'>, 'children' | 'onClick'> {
href: string;
children: ReactNode;
className?: string;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'xs' | 'sm' | 'md' | 'lg';
target?: '_blank' | '_self' | '_parent' | '_top';
rel?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
style?: React.CSSProperties;
block?: boolean;
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
truncate?: boolean;
hoverColor?: string;
transition?: boolean;
}
export function Link({
@@ -25,10 +25,11 @@ export function Link({
target = '_self',
rel = '',
onClick,
style,
block = false,
weight,
truncate,
hoverColor,
transition,
...props
}: LinkProps) {
const baseClasses = 'inline-flex items-center transition-colors';
@@ -46,7 +47,7 @@ export function Link({
lg: 'text-lg'
};
const weightClasses = {
const weightClasses: Record<string, string> = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
@@ -58,8 +59,10 @@ export function Link({
block ? 'flex' : baseClasses,
variantClasses[variant],
sizeClasses[size],
weight ? weightClasses[weight] : '',
weight && weightClasses[weight] ? weightClasses[weight] : '',
truncate ? 'truncate' : '',
hoverColor ? `hover:${hoverColor}` : '',
transition ? 'transition-all duration-150' : '',
className
].filter(Boolean).join(' ');
@@ -71,7 +74,10 @@ export function Link({
target={target}
rel={rel}
onClick={onClick}
style={style}
style={{
...(weight && !weightClasses[weight] ? { fontWeight: weight } : {}),
...(props.style || {})
}}
{...props}
>
{children}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Box } from './primitives/Box';
interface LoadingSpinnerProps {
size?: number;
@@ -7,19 +8,17 @@ interface LoadingSpinnerProps {
}
export function LoadingSpinner({ size = 8, color = '#3b82f6', className = '' }: LoadingSpinnerProps) {
const style: React.CSSProperties = {
width: `${size * 0.25}rem`,
height: `${size * 0.25}rem`,
border: '2px solid transparent',
borderTopColor: color,
borderLeftColor: color,
borderRadius: '9999px',
};
return (
<div
<Box
w={`${size * 0.25}rem`}
h={`${size * 0.25}rem`}
rounded="full"
borderWidth="2px"
borderStyle="solid"
borderColor="transparent"
borderTopColor={color}
borderLeftColor={color}
className={`animate-spin ${className}`}
style={style}
role="status"
aria-label="Loading"
/>

View File

@@ -1,93 +1,100 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
import { ImagePlaceholder } from './ImagePlaceholder';
import { Image } from './Image';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
import { Play, Image as ImageIcon } from 'lucide-react';
import { Icon } from './Icon';
export interface MediaPreviewCardProps {
src?: string;
alt?: string;
interface MediaPreviewCardProps {
type: 'image' | 'video';
src: string;
alt: string;
title?: string;
subtitle?: string;
onClick?: () => void;
aspectRatio?: string;
isLoading?: boolean;
error?: string;
onClick?: () => void;
className?: string;
actions?: React.ReactNode;
}
export function MediaPreviewCard({
type,
src,
alt = 'Media preview',
alt,
title,
subtitle,
aspectRatio = '16/9',
isLoading,
error,
onClick,
aspectRatio = '16/9',
isLoading = false,
className = '',
actions,
}: MediaPreviewCardProps) {
return (
<Box
display="flex"
flexDirection="col"
bg="bg-charcoal-outline/10"
<Surface
variant="muted"
rounded="xl"
border
borderColor="border-charcoal-outline/50"
rounded="lg"
overflow="hidden"
transition
hoverScale={!!onClick}
cursor={onClick ? 'pointer' : 'default'}
cursor="pointer"
onClick={onClick}
className={`group ${className}`}
group
className={className}
>
<Box position="relative" width="full" style={{ aspectRatio }}>
<Box position="relative" w="full" aspectRatio={aspectRatio}>
{isLoading ? (
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
) : error ? (
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
) : src ? (
<Box fullWidth fullHeight bg="bg-white/5" className="animate-pulse" />
) : (
<Image
src={src}
alt={alt}
className="w-full h-full object-cover"
fullWidth
fullHeight
className="object-cover"
/>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
{actions && (
{/* Overlay */}
<Box
position="absolute"
inset={0}
bg="bg-black/40"
display="flex"
center
opacity={0}
groupHoverOpacity={1}
transition="all 0.2s"
>
<Box
w="12"
h="12"
rounded="full"
bg="bg-white/20"
display="flex"
center
className="backdrop-blur-md"
>
<Icon
icon={type === 'video' ? Play : ImageIcon}
size={6}
color="white"
/>
</Box>
</Box>
{title && (
<Box
position="absolute"
top={2}
right={2}
display="flex"
gap={2}
opacity={0}
className="group-hover:opacity-100 transition-opacity"
bottom={0}
left={0}
right={0}
p={3}
bg="bg-gradient-to-t from-black/80 to-transparent"
>
{actions}
<Text size="xs" weight="medium" color="text-white" truncate>
{title}
</Text>
</Box>
)}
</Box>
{(title || subtitle) && (
<Box p={3} borderTop borderColor="border-charcoal-outline/30">
{title && (
<Text block size="sm" weight="semibold" truncate>
{title}
</Text>
)}
{subtitle && (
<Text block size="xs" color="text-gray-500" truncate mt={0.5}>
{subtitle}
</Text>
)}
</Box>
)}
</Box>
</Surface>
);
}

View File

@@ -1,138 +1,144 @@
import React, {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import React, { ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Button } from './Button';
import { Heading } from './Heading';
import { Stack } from './primitives/Stack';
import { Button } from './Button';
import { Text } from './Text';
import { X } from 'lucide-react';
import { IconButton } from './IconButton';
interface ModalProps {
title: string;
description?: string;
icon?: ReactNode;
children?: ReactNode;
primaryActionLabel?: string;
secondaryActionLabel?: string;
onPrimaryAction?: () => void | Promise<void>;
onSecondaryAction?: () => void;
onOpenChange?: (open: boolean) => void;
isOpen: boolean;
onClose?: () => void;
onOpenChange?: (open: boolean) => void;
title?: string;
description?: string;
icon?: React.ReactNode;
children: ReactNode;
footer?: ReactNode;
primaryActionLabel?: string;
onPrimaryAction?: () => void;
secondaryActionLabel?: string;
onSecondaryAction?: () => void;
isLoading?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function Modal({
isOpen,
onClose,
onOpenChange,
title,
description,
icon,
children,
primaryActionLabel,
secondaryActionLabel,
onPrimaryAction,
onSecondaryAction,
onOpenChange,
isOpen,
footer,
primaryActionLabel,
onPrimaryAction,
secondaryActionLabel,
onSecondaryAction,
isLoading = false,
size = 'md',
}: ModalProps) {
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
if (onOpenChange) {
onOpenChange(false);
}
return;
}
if (!isOpen) return null;
const sizeMap = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget && onOpenChange) {
onOpenChange(false);
}
const handleClose = () => {
if (onClose) onClose();
if (onOpenChange) onOpenChange(false);
};
if (!isOpen) {
return null;
}
return (
<Box
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: '0 1rem', backdropFilter: 'blur(4px)' }}
position="fixed"
inset={0}
zIndex={60}
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-black/60"
px={4}
className="backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-description' : undefined}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
>
{/* Backdrop click to close */}
<Box position="absolute" inset={0} onClick={handleClose} />
<Box
style={{ width: '100%', maxWidth: '28rem', borderRadius: '1rem', backgroundColor: '#0f1115', border: '1px solid #262626', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)', outline: 'none', overflow: 'hidden' }}
position="relative"
w="full"
maxWidth={sizeMap[size]}
rounded="2xl"
bg="bg-[#0f1115]"
border
borderColor="border-[#262626]"
shadow="2xl"
overflow="hidden"
tabIndex={-1}
>
<Box p={6} style={{ borderBottom: '1px solid rgba(38, 38, 38, 0.8)' }}>
<Stack direction="row" align="center" gap={3}>
{icon && <Box>{icon}</Box>}
<Box>
<Heading level={2} id="modal-title">{title}</Heading>
{description && (
<Text
id="modal-description"
size="sm"
color="text-gray-400"
block
mt={1}
>
{description}
</Text>
)}
</Box>
{/* Header */}
<Box p={6} borderBottom borderColor="border-white/5">
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
{icon && <Box>{icon}</Box>}
<Box>
{title && (
<Text size="xl" weight="bold" color="text-white" block>
{title}
</Text>
)}
{description && (
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Box>
</Stack>
<IconButton
icon={X}
onClick={handleClose}
variant="ghost"
size="sm"
title="Close modal"
/>
</Stack>
</Box>
<Box p={6}>
{/* Content */}
<Box p={6} overflowY="auto" maxHeight="calc(100vh - 200px)">
{children}
</Box>
{/* Footer */}
{(primaryActionLabel || secondaryActionLabel || footer) && (
<Box p={6} style={{ borderTop: '1px solid rgba(38, 38, 38, 0.8)' }}>
{(primaryActionLabel || secondaryActionLabel) && (
<Box style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<Box p={6} borderTop borderColor="border-white/5">
{footer || (
<Stack direction="row" justify="end" gap={3}>
{secondaryActionLabel && (
<Button
type="button"
onClick={() => {
onSecondaryAction?.();
onOpenChange?.(false);
}}
variant="secondary"
size="sm"
fullWidth={!primaryActionLabel}
variant="ghost"
onClick={onSecondaryAction || onClose}
disabled={isLoading}
>
{secondaryActionLabel}
</Button>
)}
{primaryActionLabel && (
<Button
type="button"
onClick={async () => {
if (onPrimaryAction) {
await onPrimaryAction();
}
}}
variant="primary"
size="sm"
fullWidth={!secondaryActionLabel}
onClick={onPrimaryAction}
isLoading={isLoading}
>
{primaryActionLabel}
</Button>
)}
</Box>
)}
{footer && (
<Box mt={4}>
{footer}
</Box>
</Stack>
)}
</Box>
)}

View File

@@ -1,12 +1,26 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
interface OnboardingErrorProps {
message: string;
}
export function OnboardingError({ message }: OnboardingErrorProps) {
return (
<div className="mt-6 flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<span className="text-red-400 flex-shrink-0 mt-0.5"></span>
<p className="text-sm text-red-400">{message}</p>
</div>
<Box
mt={6}
display="flex"
alignItems="start"
gap={3}
p={4}
rounded="xl"
bg="bg-red-500/10"
border
borderColor="border-red-500/30"
>
<Text color="text-red-400" flexShrink={0} mt={0.5}></Text>
<Text size="sm" color="text-red-400">{message}</Text>
</Box>
);
}
}

View File

@@ -1,12 +1,15 @@
import React, { ReactNode, FormEvent } from 'react';
import { Box } from './primitives/Box';
interface OnboardingFormProps {
children: React.ReactNode;
onSubmit: (e: React.FormEvent) => void | Promise<void>;
children: ReactNode;
onSubmit: (e: FormEvent) => void;
}
export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) {
return (
<form onSubmit={onSubmit} className="relative">
<Box as="form" onSubmit={onSubmit} position="relative">
{children}
</form>
</Box>
);
}
}

View File

@@ -1,18 +1,20 @@
import { Surface } from '@/ui/primitives/Surface';
import React, { ReactNode } from 'react';
import { Surface } from './primitives/Surface';
interface OnboardingStepPanelProps {
children: React.ReactNode;
children: ReactNode;
className?: string;
}
export function OnboardingStepPanel({ children, className = '' }: OnboardingStepPanelProps) {
return (
<Surface
variant="dark"
rounded="xl"
variant="muted"
rounded="2xl"
border
padding={6}
className={`border-charcoal-outline ${className}`}
p={6}
borderColor="border-charcoal-outline"
className={className}
>
{children}
</Surface>

View File

@@ -1,8 +1,7 @@
import { Box } from '@/ui/primitives/Box';
import { Grid } from '@/ui/primitives/Grid';
import { Text } from '@/ui/Text';
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
import { Grid } from './primitives/Grid';
interface Stat {
label: string;
@@ -16,11 +15,19 @@ interface ProfileStatGridProps {
export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
return (
<Grid cols={2} gap={4}>
<Grid cols={2} mdCols={4} gap={4}>
{stats.map((stat, idx) => (
<Box key={idx} p={4} style={{ backgroundColor: '#0f1115', borderRadius: '0.75rem', border: '1px solid #262626', textAlign: 'center' }}>
<Box
key={idx}
p={4}
bg="bg-[#0f1115]"
rounded="xl"
border
borderColor="border-[#262626]"
textAlign="center"
>
<Text size="3xl" weight="bold" color={stat.color} block mb={1}>{stat.value}</Text>
<Text size="xs" color="text-gray-500" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{stat.label}</Text>
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em">{stat.label}</Text>
</Box>
))}
</Grid>

View File

@@ -1,46 +1,50 @@
import { LucideIcon } from 'lucide-react';
import React from 'react';
import React, { ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Stack } from './primitives/Stack';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface SectionHeaderProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
icon?: LucideIcon;
color?: string;
actions?: ReactNode;
}
export function SectionHeader({
icon,
title,
description,
action,
color = '#3b82f6'
}: SectionHeaderProps) {
export function SectionHeader({ title, description, icon, color = 'text-primary-blue', actions }: SectionHeaderProps) {
return (
<Box p={5} style={{ borderBottom: '1px solid #262626', background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}>
<Box
p={5}
borderBottom
borderColor="border-white/5"
style={{ background: 'linear-gradient(to right, rgba(38, 38, 38, 0.3), transparent)' }}
>
<Stack direction="row" align="center" justify="between" wrap gap={4}>
<Box>
<Stack direction="row" align="center" gap={3}>
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)' }}>
<Stack direction="row" align="center" gap={3}>
{icon && (
<Surface variant="muted" rounded="lg" p={2} bg="bg-white/5">
<Icon icon={icon} size={5} color={color} />
</Surface>
<Box>
<Heading level={2}>{title}</Heading>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
)}
</Box>
</Stack>
</Box>
{action && <Box>{action}</Box>}
)}
<Box>
<Text size="lg" weight="bold" color="text-white" block>
{title}
</Text>
{description && (
<Text size="sm" color="text-gray-400" block>
{description}
</Text>
)}
</Box>
</Stack>
{actions && (
<Box>
{actions}
</Box>
)}
</Stack>
</Box>
);

View File

@@ -1,5 +1,3 @@
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
@@ -29,7 +27,16 @@ export function SegmentedControl({
};
return (
<Box style={{ display: 'inline-flex', width: '100%', flexWrap: 'wrap', gap: '0.5rem', borderRadius: '9999px', backgroundColor: 'rgba(38, 38, 38, 0.6)', padding: '0.25rem' }}>
<Stack
direction="row"
display="inline-flex"
w="full"
flexWrap="wrap"
gap={2}
rounded="full"
bg="bg-black/60"
p={1}
>
{options.map((option) => {
const isSelected = option.value === value;
@@ -41,24 +48,28 @@ export function SegmentedControl({
onClick={() => handleSelect(option.value, option.disabled)}
aria-pressed={isSelected}
disabled={option.disabled}
style={{
flex: 1,
minWidth: '140px',
padding: '0.375rem 0.75rem',
borderRadius: '9999px',
transition: 'all 0.2s',
textAlign: 'left',
backgroundColor: isSelected ? '#3b82f6' : 'transparent',
color: isSelected ? 'white' : '#d1d5db',
opacity: option.disabled ? 0.5 : 1,
cursor: option.disabled ? 'not-allowed' : 'pointer',
border: 'none'
}}
flex={1}
minWidth="140px"
px={3}
py={1.5}
rounded="full"
transition="all 0.2s"
textAlign="left"
bg={isSelected ? 'bg-primary-blue' : 'transparent'}
color={isSelected ? 'text-white' : 'text-gray-400'}
opacity={option.disabled ? 0.5 : 1}
cursor={option.disabled ? 'not-allowed' : 'pointer'}
border="none"
>
<Stack gap={0.5}>
<Text size="xs" weight="medium" color="inherit">{option.label}</Text>
{option.description && (
<Text size="xs" color={isSelected ? 'text-white' : 'text-gray-400'} style={{ fontSize: '10px', opacity: isSelected ? 0.8 : 1 }}>
<Text
size="xs"
color={isSelected ? 'text-white' : 'text-gray-400'}
fontSize="10px"
opacity={isSelected ? 0.8 : 1}
>
{option.description}
</Text>
)}
@@ -66,6 +77,6 @@ export function SegmentedControl({
</Box>
);
})}
</Box>
</Stack>
);
}

View File

@@ -1,4 +1,5 @@
import React, { ChangeEvent, SelectHTMLAttributes } from 'react';
import React, { forwardRef, ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
@@ -7,64 +8,57 @@ interface SelectOption {
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
id?: string;
'aria-label'?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
options: SelectOption[];
className?: string;
style?: React.CSSProperties;
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
fullWidth?: boolean;
pl?: number;
errorMessage?: string;
variant?: 'default' | 'error';
options?: SelectOption[];
}
export function Select({
id,
'aria-label': ariaLabel,
value,
onChange,
options,
className = '',
style,
label,
fullWidth = true,
pl,
...props
}: SelectProps) {
const spacingMap: Record<number, string> = {
10: 'pl-10'
};
const defaultClasses = `${fullWidth ? 'w-full' : 'w-auto'} px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors`;
const classes = [
defaultClasses,
pl !== undefined ? spacingMap[pl] : '',
className
].filter(Boolean).join(' ');
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ label, fullWidth = true, pl, errorMessage, variant = 'default', options, children, className = '', style, ...props }, ref) => {
const isError = variant === 'error' || !!errorMessage;
const variantClasses = isError
? 'border-warning-amber focus:border-warning-amber'
: 'border-charcoal-outline focus:border-primary-blue';
const defaultClasses = `${fullWidth ? 'w-full' : 'w-auto'} px-3 py-2 bg-deep-graphite border rounded-lg text-white focus:outline-none transition-colors`;
const classes = [
defaultClasses,
variantClasses,
pl ? `pl-${pl}` : '',
className
].filter(Boolean).join(' ');
return (
<Stack gap={1.5} fullWidth={fullWidth}>
{label && (
<Text as="label" size="sm" weight="medium" color="text-gray-400">
{label}
</Text>
)}
<select
id={id}
aria-label={ariaLabel}
value={value}
onChange={onChange}
className={classes}
style={style}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Stack>
);
}
return (
<Stack gap={1.5} fullWidth={fullWidth}>
{label && (
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{label}
</Text>
)}
<Box
as="select"
ref={ref}
className={classes}
style={style}
{...props}
>
{options ? options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
)) : children}
</Box>
{errorMessage && (
<Text size="xs" color="text-warning-amber" mt={1}>
{errorMessage}
</Text>
)}
</Stack>
);
}
);
Select.displayName = 'Select';

View File

@@ -28,7 +28,8 @@ export function SimpleCheckbox({ checked, onChange, disabled, 'aria-label': aria
borderColor="border-charcoal-outline"
rounded="sm"
aria-label={ariaLabel}
className="text-primary-blue focus:ring-primary-blue"
ring="primary-blue"
color="text-primary-blue"
/>
);
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Box } from './primitives/Box';
interface SkeletonProps {
width?: string | number;
@@ -8,17 +9,13 @@ interface SkeletonProps {
}
export function Skeleton({ width, height, circle, className = '' }: SkeletonProps) {
const style: React.CSSProperties = {
width: width,
height: height,
borderRadius: circle ? '9999px' : '0.375rem',
backgroundColor: 'rgba(38, 38, 38, 0.4)',
};
return (
<div
<Box
w={width}
h={height}
rounded={circle ? 'full' : 'md'}
bg="bg-white/5"
className={`animate-pulse ${className}`}
style={style}
role="status"
aria-label="Loading..."
/>

View File

@@ -1,115 +1,115 @@
import { motion, useReducedMotion } from 'framer-motion';
import { ArrowDownRight, ArrowUpRight, LucideIcon } from 'lucide-react';
import React, { ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Card } from './Card';
import { Icon } from './Icon';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
import { Card } from './Card';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface StatCardProps {
label: string;
value: string | number;
subValue?: string;
icon?: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange';
className?: string;
trend?: {
value: number;
isPositive: boolean;
};
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
className?: string;
onClick?: () => void;
prefix?: string;
suffix?: string;
delay?: number;
}
export function StatCard({
label,
value,
subValue,
export function StatCard({
label,
value,
icon,
variant = 'blue',
className = '',
trend,
prefix = '',
suffix = '',
delay = 0,
variant = 'default',
className = '',
onClick,
prefix,
suffix,
delay,
}: StatCardProps) {
const shouldReduceMotion = useReducedMotion();
const variantClasses = {
blue: 'bg-gradient-to-br from-blue-900/20 to-blue-700/10 border-blue-500/30',
purple: 'bg-gradient-to-br from-purple-900/20 to-purple-700/10 border-purple-500/30',
green: 'bg-gradient-to-br from-green-900/20 to-green-700/10 border-green-500/30',
orange: 'bg-gradient-to-br from-orange-900/20 to-orange-700/10 border-orange-500/30'
default: 'bg-panel-gray border-border-gray',
primary: 'bg-primary-accent/5 border-primary-accent/20',
success: 'bg-success-green/5 border-success-green/20',
warning: 'bg-warning-amber/5 border-warning-amber/20',
danger: 'bg-critical-red/5 border-critical-red/20',
info: 'bg-telemetry-aqua/5 border-telemetry-aqua/20',
};
const iconBgClasses = {
default: 'bg-white/5',
primary: 'bg-primary-accent/10',
success: 'bg-success-green/10',
warning: 'bg-warning-amber/10',
danger: 'bg-critical-red/10',
info: 'bg-telemetry-aqua/10',
};
const iconColorClasses = {
blue: 'text-primary-blue',
purple: 'text-purple-400',
green: 'text-performance-green',
orange: 'text-warning-amber'
default: 'text-gray-400',
primary: 'text-primary-accent',
success: 'text-success-green',
warning: 'text-warning-amber',
danger: 'text-critical-red',
info: 'text-telemetry-aqua',
};
const cardContent = (
<Card className={`${variantClasses[variant]} ${className} h-full`} p={5}>
<Card variant="default" p={5} className={`${variantClasses[variant]} ${className} h-full`}>
<Stack gap={3}>
<Stack direction="row" align="start" justify="between">
<Stack direction="row" align="center" justify="between">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
{label}
</Text>
{icon && (
<Box
width="11"
height="11"
rounded="xl"
display="flex"
center
bg="bg-iron-gray/50"
border={true}
borderColor="border-charcoal-outline"
p={2}
rounded="lg"
bg={iconBgClasses[variant]}
className={iconColorClasses[variant]}
>
<Icon icon={icon} size={5} className={iconColorClasses[variant]} />
<Icon icon={icon} size={5} />
</Box>
)}
</Stack>
<Stack gap={1}>
<Text size="3xl" weight="bold" color="text-white">
{prefix}{value}{suffix}
</Text>
{trend && (
<Stack
direction="row"
align="center"
gap={1}
color={trend.isPositive ? 'text-performance-green' : 'text-error-red'}
>
<Icon icon={trend.isPositive ? ArrowUpRight : ArrowDownRight} size={4} />
<Text size="sm" weight="medium">{Math.abs(trend.value)}%</Text>
<Stack direction="row" align="center" gap={1}>
<Text
size="xs"
weight="bold"
color={trend.isPositive ? 'text-success-green' : 'text-critical-red'}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</Text>
<Text size="xs" color="text-gray-500">
vs last period
</Text>
</Stack>
)}
</Stack>
<Box>
<Text size="2xl" weight="bold" color="text-white" block mb={1}>
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
</Text>
<Text size="sm" color="text-gray-400" block>{label}</Text>
{subValue && (
<Text size="xs" color="text-gray-500" block mt={1}>
{subValue}
</Text>
)}
</Box>
</Stack>
</Card>
);
if (shouldReduceMotion) {
return <Box fullHeight>{cardContent}</Box>;
if (onClick) {
return (
<Box as="button" onClick={onClick} w="full" textAlign="left" className="focus:outline-none">
{cardContent}
</Box>
);
}
return (
<Box
as={motion.div}
fullHeight
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
{cardContent}
</Box>
);
return cardContent;
}

View File

@@ -1,37 +1,36 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import React, { ReactNode } from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
import { Icon } from './Icon';
import { Stack } from './primitives/Stack';
import { LucideIcon } from 'lucide-react';
interface StatGridItemProps {
label: string;
value: string | number;
color?: string;
icon?: LucideIcon;
color?: string;
}
export function StatGridItem({ label, value, color = 'text-white', icon }: StatGridItemProps) {
/**
* StatGridItem
*
* A simple stat display for use in a grid.
*/
export function StatGridItem({ label, value, icon, color = 'text-primary-blue' }: StatGridItemProps) {
return (
<Box
p={4}
bg="bg-deep-graphite/60"
rounded="xl"
border={true}
borderColor="border-charcoal-outline"
textAlign="center"
>
<Box p={4} textAlign="center">
{icon && (
<Stack direction="row" align="center" justify="center" gap={2} mb={1} className={color}>
<Stack direction="row" align="center" justify="center" gap={2} mb={1} color={color}>
<Icon icon={icon} size={4} />
<Text size="xs" weight="medium" uppercase letterSpacing="0.05em">{label}</Text>
</Stack>
)}
{!icon && (
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em" block mb={1}>{label}</Text>
)}
<Text size="3xl" weight="bold" color={color} block>{value}</Text>
<Text size="2xl" weight="bold" color="text-white" block>
{value}
</Text>
<Text size="xs" weight="medium" color="text-gray-500" uppercase letterSpacing="wider">
{label}
</Text>
</Box>
);
}

View File

@@ -1,43 +1,48 @@
import { ReactNode } from 'react';
import React from 'react';
import { Box } from './primitives/Box';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
import { Surface } from './primitives/Surface';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface SummaryItemProps {
title: string;
subtitle?: string;
rightContent?: ReactNode;
label?: string;
value?: string | number;
icon?: LucideIcon;
onClick?: () => void;
title?: string;
subtitle?: string;
rightContent?: React.ReactNode;
}
export function SummaryItem({
title,
subtitle,
rightContent,
onClick,
}: SummaryItemProps) {
export function SummaryItem({ label, value, icon, onClick, title, subtitle, rightContent }: SummaryItemProps) {
return (
<Surface
variant="muted"
padding={3}
rounded="lg"
p={4}
display="flex"
alignItems="center"
gap={4}
cursor={onClick ? 'pointer' : 'default'}
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: onClick ? 'pointer' : 'default',
}}
hoverBg={onClick ? 'bg-white/5' : undefined}
transition={!!onClick}
>
<Box>
<Text color="text-white" weight="medium" block>
{title}
</Text>
{subtitle && (
<Text size="xs" color="text-gray-500">
{subtitle}
{icon && (
<Box p={2} rounded="lg" bg="bg-white/5">
<Icon icon={icon} size={5} color="text-gray-400" />
</Box>
)}
<Box flex={1}>
{(label || title) && (
<Text size="xs" color="text-gray-500" uppercase letterSpacing="wider" block>
{label || title}
</Text>
)}
{(value || subtitle) && (
<Text size="lg" weight="bold" color="text-white" block>
{value || subtitle}
</Text>
)}
</Box>

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Surface } from './primitives/Surface';
import { Text } from './Text';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface Tab {
id: string;
label: string;
icon?: LucideIcon;
icon?: React.ReactNode;
}
interface TabNavigationProps {
@@ -19,51 +19,50 @@ interface TabNavigationProps {
export function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) {
return (
<Box
display="flex"
alignItems="center"
gap={1}
p={1.5}
<Surface
variant="muted"
rounded="xl"
bg="bg-iron-gray/50"
border
borderColor="border-charcoal-outline"
w="fit"
position="relative"
p={1}
display="inline-flex"
zIndex={10}
className={className}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Box
key={tab.id}
as="button"
type="button"
onClick={() => onTabChange(tab.id)}
display="flex"
alignItems="center"
gap={2}
px={5}
py={2.5}
rounded="lg"
cursor="pointer"
transition
bg={isActive ? 'bg-primary-blue' : ''}
className={`select-none ${isActive ? 'shadow-lg shadow-primary-blue/25' : 'hover:bg-iron-gray/80'}`}
>
{tab.icon && <Icon icon={tab.icon} size={4} color={isActive ? 'text-white' : 'text-gray-400'} />}
<Text
size="sm"
weight="medium"
color={isActive ? 'text-white' : 'text-gray-400'}
className={!isActive ? 'hover:text-white' : ''}
<Stack direction="row" gap={1}>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<Surface
key={tab.id}
as="button"
onClick={() => onTabChange(tab.id)}
variant={isActive ? 'default' : 'ghost'}
bg={isActive ? 'bg-primary-blue' : ''}
rounded="lg"
px={4}
py={2}
transition="all 0.2s"
group
className={`select-none ${isActive ? 'shadow-lg shadow-primary-blue/25' : 'hover:bg-iron-gray/80'}`}
>
{tab.label}
</Text>
</Box>
);
})}
</Box>
<Stack direction="row" align="center" gap={2}>
{tab.icon && (
<Box color={isActive ? 'text-white' : 'text-gray-400'} groupHoverTextColor={!isActive ? 'white' : undefined}>
{tab.icon}
</Box>
)}
<Text
size="sm"
weight="medium"
color={isActive ? 'text-white' : 'text-gray-400'}
groupHoverTextColor={!isActive ? 'white' : undefined}
>
{tab.label}
</Text>
</Stack>
</Surface>
);
})}
</Stack>
</Surface>
);
}

View File

@@ -1,58 +1,59 @@
import React, { ReactNode, HTMLAttributes } from 'react';
import React, { ReactNode, ElementType } from 'react';
import { Box, BoxProps } from './primitives/Box';
interface TableProps extends HTMLAttributes<HTMLTableElement> {
interface TableProps extends BoxProps<'table'> {
children: ReactNode;
className?: string;
}
export function Table({ children, className = '', ...props }: TableProps) {
const { border, translate, ...rest } = props;
return (
<Box overflow="auto" className="border border-border-gray rounded-sm">
<table className={`w-full border-collapse text-left ${className}`} {...props}>
<Box overflow="auto" border borderColor="border-border-gray" rounded="sm">
<table className={`w-full border-collapse text-left ${className}`} {...(rest as any)}>
{children}
</table>
</Box>
);
}
interface TableHeadProps extends HTMLAttributes<HTMLTableSectionElement> {
interface TableHeaderProps extends BoxProps<'thead'> {
children: ReactNode;
}
export function TableHead({ children, ...props }: TableHeadProps) {
export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
return (
<thead className="bg-graphite-black border-b border-border-gray" {...props}>
<Box as="thead" className={`bg-graphite-black border-b border-border-gray ${className}`} {...props}>
{children}
</thead>
</Box>
);
}
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
export const TableHead = TableHeader;
interface TableBodyProps extends BoxProps<'tbody'> {
children: ReactNode;
}
export function TableBody({ children, ...props }: TableBodyProps) {
export function TableBody({ children, className = '', ...props }: TableBodyProps) {
return (
<tbody className="divide-y divide-border-gray/50" {...props}>
<Box as="tbody" className={`divide-y divide-border-gray/50 ${className}`} {...props}>
{children}
</tbody>
</Box>
);
}
interface TableRowProps extends BoxProps<'tr'> {
children: ReactNode;
hoverBg?: string;
clickable?: boolean;
variant?: 'default' | 'highlight';
variant?: string;
}
export function TableRow({ children, className = '', clickable = false, variant = 'default', ...props }: TableRowProps) {
const baseClasses = 'transition-colors duration-150 ease-smooth';
const variantClasses = variant === 'highlight' ? 'bg-primary-accent/5' : 'hover:bg-white/[0.02]';
export function TableRow({ children, className = '', hoverBg, clickable, variant, ...props }: TableRowProps) {
const classes = [
baseClasses,
variantClasses,
clickable ? 'cursor-pointer' : '',
'transition-colors',
clickable || props.onClick ? 'cursor-pointer' : '',
hoverBg ? `hover:${hoverBg}` : (clickable || props.onClick ? 'hover:bg-white/5' : ''),
className
].filter(Boolean).join(' ');
@@ -63,13 +64,15 @@ export function TableRow({ children, className = '', clickable = false, variant
);
}
interface TableHeaderProps extends BoxProps<'th'> {
interface TableCellProps extends BoxProps<'td'> {
children: ReactNode;
}
export function TableHeader({ children, className = '', ...props }: TableHeaderProps) {
const baseClasses = 'py-2.5 px-4 text-[11px] font-bold text-gray-500 uppercase tracking-wider';
const classes = [baseClasses, className].filter(Boolean).join(' ');
export function TableHeaderCell({ children, className = '', ...props }: TableCellProps) {
const classes = [
'px-4 py-3 text-xs font-bold text-gray-400 uppercase tracking-wider',
className
].filter(Boolean).join(' ');
return (
<Box as="th" className={classes} {...props}>
@@ -78,13 +81,11 @@ export function TableHeader({ children, className = '', ...props }: TableHeaderP
);
}
interface TableCellProps extends BoxProps<'td'> {
children: ReactNode;
}
export function TableCell({ children, className = '', ...props }: TableCellProps) {
const baseClasses = 'py-3 px-4 text-sm text-gray-300';
const classes = [baseClasses, className].filter(Boolean).join(' ');
const classes = [
'px-4 py-4 text-sm text-gray-300',
className
].filter(Boolean).join(' ');
return (
<Box as="td" className={classes} {...props}>

View File

@@ -25,14 +25,14 @@ interface ResponsiveTextAlign {
'2xl'?: TextAlign;
}
interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'children' | 'className'> {
interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'children' | 'className' | 'size'> {
as?: T;
children: ReactNode;
className?: string;
size?: TextSize | ResponsiveTextSize;
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | string;
color?: string;
font?: 'mono' | 'sans';
font?: 'mono' | 'sans' | string;
align?: TextAlign | ResponsiveTextAlign;
truncate?: boolean;
uppercase?: boolean;
@@ -43,6 +43,7 @@ interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'c
style?: React.CSSProperties;
block?: boolean;
italic?: boolean;
lineClamp?: number;
ml?: Spacing | ResponsiveSpacing;
mr?: Spacing | ResponsiveSpacing;
mt?: Spacing | ResponsiveSpacing;
@@ -76,6 +77,7 @@ export function Text<T extends ElementType = 'span'>({
style,
block = false,
italic = false,
lineClamp,
ml, mr, mt, mb,
...props
}: TextProps<T> & ComponentPropsWithoutRef<T>) {
@@ -115,7 +117,7 @@ export function Text<T extends ElementType = 'span'>({
bold: 'font-bold'
};
const fontClasses = {
const fontClasses: Record<string, string> = {
mono: 'font-mono',
sans: 'font-sans'
};
@@ -175,8 +177,8 @@ export function Text<T extends ElementType = 'span'>({
const classes = [
block ? 'block' : 'inline',
getSizeClasses(size),
weightClasses[weight],
fontClasses[font],
weightClasses[weight] || '',
fontClasses[font] || '',
getAlignClasses(align),
leading ? leadingClasses[leading] : '',
color,
@@ -184,6 +186,7 @@ export function Text<T extends ElementType = 'span'>({
uppercase ? 'uppercase' : '',
capitalize ? 'capitalize' : '',
italic ? 'italic' : '',
lineClamp ? `line-clamp-${lineClamp}` : '',
letterSpacing === '0.05em' ? 'tracking-wider' : letterSpacing ? `tracking-${letterSpacing}` : '',
getSpacingClass('ml', ml),
getSpacingClass('mr', mr),
@@ -194,6 +197,8 @@ export function Text<T extends ElementType = 'span'>({
const combinedStyle = {
...(fontSize ? { fontSize } : {}),
...(weight && !weightClasses[weight] ? { fontWeight: weight } : {}),
...(font && !fontClasses[font] ? { fontFamily: font } : {}),
...style
};

View File

@@ -1,56 +1,49 @@
import React, { TextareaHTMLAttributes } from 'react';
import React, { forwardRef } from 'react';
import { Box } from './primitives/Box';
import { Stack } from './primitives/Stack';
import { Text } from './Text';
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: React.ReactNode;
interface TextAreaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
errorMessage?: string;
variant?: 'default' | 'error';
fullWidth?: boolean;
}
export function TextArea({
label,
errorMessage,
variant = 'default',
fullWidth = true,
className = '',
...props
}: TextAreaProps) {
const isError = variant === 'error' || !!errorMessage;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ label, errorMessage, variant = 'default', fullWidth = true, className = '', ...props }, ref) => {
const isError = variant === 'error' || !!errorMessage;
return (
<Stack gap={1.5} fullWidth={fullWidth}>
{label && (
<Text as="label" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{label}
</Text>
)}
<Box position="relative" fullWidth={fullWidth}>
<Box
as="textarea"
ref={ref}
fullWidth={fullWidth}
p={3}
bg="bg-deep-graphite"
rounded="lg"
color="text-white"
border
borderColor={isError ? 'var(--warning-amber)' : 'rgba(38, 38, 38, 0.8)'}
className={`placeholder:text-gray-500 focus:ring-2 focus:ring-primary-blue transition-all duration-150 sm:text-sm ${className}`}
{...props}
/>
{errorMessage && (
<Text size="xs" color="text-warning-amber" mt={1}>
{errorMessage}
</Text>
)}
</Box>
</Stack>
);
}
);
return (
<Stack gap={1.5} fullWidth={fullWidth}>
{label && (
<Text as="label" size="sm" weight="medium" color="text-gray-300">
{label}
</Text>
)}
<Box position="relative" fullWidth={fullWidth}>
<Box
as="textarea"
fullWidth={fullWidth}
p={3}
rounded="md"
bg="bg-iron-gray"
color="text-white"
border
style={{
borderColor: isError ? 'var(--warning-amber)' : 'rgba(38, 38, 38, 0.8)',
resize: 'none',
}}
className={`placeholder:text-gray-500 focus:ring-2 focus:ring-primary-blue transition-all duration-150 sm:text-sm ${className}`}
{...props}
/>
</Box>
{errorMessage && (
<Text size="xs" color="text-warning-amber">
{errorMessage}
</Text>
)}
</Stack>
);
}
TextArea.displayName = 'TextArea';

View File

@@ -1,68 +1,74 @@
import { motion } from 'framer-motion';
import React from 'react';
import { Box } from './primitives/Box';
import { Text } from './Text';
import { motion } from 'framer-motion';
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description?: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Toggle({
checked,
onChange,
label,
description,
disabled = false,
}: ToggleProps) {
export function Toggle({ label, description, checked, onChange, disabled }: ToggleProps) {
return (
<label className={`flex items-start justify-between cursor-pointer py-3 border-b border-charcoal-outline/50 last:border-b-0 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}>
<Box style={{ flex: 1, paddingRight: '1rem' }}>
<Box
as="label"
display="flex"
alignItems="start"
justifyContent="between"
cursor={disabled ? 'not-allowed' : 'pointer'}
py={3}
borderBottom
borderColor="border-charcoal-outline/50"
className="last:border-b-0"
opacity={disabled ? 0.5 : 1}
>
<Box flex={1} pr={4}>
<Text weight="medium" color="text-gray-200" block>{label}</Text>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>{description}</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
{description}
</Text>
)}
</Box>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
className={`relative w-12 h-6 rounded-full transition-colors duration-200 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-primary-blue/50 ${
checked
? 'bg-primary-blue'
: 'bg-iron-gray'
} ${disabled ? 'cursor-not-allowed' : ''}`}
>
{/* Glow effect when active */}
{checked && (
<Box position="relative">
<Box
as="button"
type="button"
role="switch"
aria-checked={checked}
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
w="12"
h="6"
rounded="full"
transition="all 0.2s"
flexShrink={0}
ring="primary-blue/50"
bg={checked ? 'bg-primary-blue/20' : 'bg-charcoal-outline'}
className="focus:outline-none focus:ring-2"
>
<motion.div
className="absolute inset-0 rounded-full bg-primary-blue"
initial={{ boxShadow: '0 0 0px rgba(25, 140, 255, 0)' }}
animate={{ boxShadow: '0 0 12px rgba(25, 140, 255, 0.4)' }}
transition={{ duration: 0.2 }}
animate={{
opacity: checked ? 1 : 0,
boxShadow: checked ? '0 0 10px rgba(25, 140, 255, 0.4)' : '0 0 0px rgba(25, 140, 255, 0)'
}}
/>
)}
{/* Knob */}
<motion.span
</Box>
<motion.span
className="absolute top-0.5 w-5 h-5 bg-white rounded-full shadow-md"
initial={false}
animate={{
x: checked ? 24 : 2,
scale: 1,
}}
whileTap={{ scale: disabled ? 1 : 0.9 }}
transition={{
type: 'spring',
stiffness: 500,
damping: 30,
left: checked ? '26px' : '2px',
}}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
</label>
</Box>
</Box>
);
}

View File

@@ -16,8 +16,10 @@ type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 1
interface ResponsiveSpacing {
base?: Spacing;
sm?: Spacing;
md?: Spacing;
lg?: Spacing;
xl?: Spacing;
}
export type ResponsiveValue<T> = {
@@ -49,38 +51,151 @@ export interface BoxProps<T extends ElementType> {
px?: Spacing | ResponsiveSpacing;
py?: Spacing | ResponsiveSpacing;
// Sizing
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
width?: string;
height?: string;
w?: string | number | ResponsiveValue<string | number>;
h?: string | number | ResponsiveValue<string | number>;
width?: string | number;
height?: string | number;
maxWidth?: string | ResponsiveValue<string>;
minWidth?: string | ResponsiveValue<string>;
maxHeight?: string | ResponsiveValue<string>;
minHeight?: string | ResponsiveValue<string>;
fullWidth?: boolean;
fullHeight?: boolean;
aspectRatio?: string;
// Display
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | ResponsiveValue<'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none'>;
display?: 'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | string | ResponsiveValue<'block' | 'inline-block' | 'flex' | 'inline-flex' | 'grid' | 'none' | string>;
center?: boolean;
overflow?: 'auto' | 'hidden' | 'visible' | 'scroll' | string;
overflowX?: 'auto' | 'hidden' | 'visible' | 'scroll';
overflowY?: 'auto' | 'hidden' | 'visible' | 'scroll';
textAlign?: 'left' | 'center' | 'right' | 'justify' | string;
visibility?: 'visible' | 'hidden' | 'collapse';
// Positioning
position?: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';
top?: string | number | ResponsiveValue<string | number>;
right?: string | number | ResponsiveValue<string | number>;
bottom?: string | number | ResponsiveValue<string | number>;
left?: string | number | ResponsiveValue<string | number>;
inset?: string | number;
insetY?: string | number;
insetX?: string | number;
zIndex?: number;
// Basic Styling
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | string | boolean;
border?: boolean | string;
borderTop?: boolean | string;
borderBottom?: boolean | string;
borderLeft?: boolean | string;
borderRight?: boolean | string;
borderWidth?: string | number;
borderStyle?: 'solid' | 'dashed' | 'dotted' | 'none' | string;
borderColor?: string;
borderOpacity?: number;
bg?: string;
backgroundColor?: string;
backgroundImage?: string;
backgroundSize?: string;
backgroundPosition?: string;
bgOpacity?: number;
color?: string;
shadow?: string;
opacity?: number;
blur?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | string;
pointerEvents?: 'auto' | 'none' | string;
// Flex/Grid Item props
flex?: number | string;
flexShrink?: number;
flexGrow?: number;
flexDirection?: 'row' | 'row-reverse' | 'col' | 'col-reverse' | string | ResponsiveValue<string>;
flexWrap?: 'wrap' | 'nowrap' | 'wrap-reverse' | string;
alignItems?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' | string | ResponsiveValue<string>;
justifyContent?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' | string | ResponsiveValue<string>;
alignSelf?: 'auto' | 'start' | 'end' | 'center' | 'stretch' | 'baseline';
gap?: number | string | ResponsiveValue<number | string>;
gridCols?: number | ResponsiveValue<number>;
responsiveGridCols?: number | ResponsiveValue<number>;
colSpan?: number | ResponsiveValue<number>;
responsiveColSpan?: number | ResponsiveValue<number>;
order?: number | string | ResponsiveValue<number | string>;
// Transform
transform?: string | boolean;
translate?: string;
translateX?: string;
translateY?: string;
// Animation (Framer Motion support)
initial?: any;
animate?: any;
exit?: any;
transition?: any;
variants?: any;
whileHover?: any;
whileTap?: any;
onHoverStart?: any;
onHoverEnd?: any;
whileInView?: any;
viewport?: any;
custom?: any;
// Interaction
group?: boolean;
groupHoverTextColor?: string;
groupHoverScale?: boolean;
groupHoverOpacity?: number;
groupHoverBorderColor?: string;
hoverBorderColor?: string;
hoverBg?: string;
hoverTextColor?: string;
hoverScale?: boolean | number;
clickable?: boolean;
// Events
onMouseEnter?: React.MouseEventHandler<T>;
onMouseLeave?: React.MouseEventHandler<T>;
onClick?: React.MouseEventHandler<T>;
onMouseEnter?: React.MouseEventHandler<any>;
onMouseLeave?: React.MouseEventHandler<any>;
onClick?: React.MouseEventHandler<any>;
onMouseDown?: React.MouseEventHandler<any>;
onMouseUp?: React.MouseEventHandler<any>;
onMouseMove?: React.MouseEventHandler<any>;
onKeyDown?: React.KeyboardEventHandler<any>;
onBlur?: React.FocusEventHandler<any>;
onSubmit?: React.FormEventHandler<any>;
onScroll?: React.UIEventHandler<any>;
style?: React.CSSProperties;
id?: string;
role?: string;
role?: React.AriaRole;
tabIndex?: number;
// Other
type?: 'button' | 'submit' | 'reset' | string;
disabled?: boolean;
cursor?: string;
fontSize?: string | ResponsiveValue<string>;
weight?: string;
fontWeight?: string | number;
letterSpacing?: string;
lineHeight?: string | number;
font?: string;
ring?: string;
hideScrollbar?: boolean;
truncate?: boolean;
src?: string;
alt?: string;
draggable?: boolean;
min?: string | number;
max?: string | number;
step?: string | number;
value?: string | number;
onChange?: React.ChangeEventHandler<any>;
placeholder?: string;
title?: string;
padding?: Spacing | ResponsiveSpacing;
paddingLeft?: Spacing | ResponsiveSpacing;
paddingRight?: Spacing | ResponsiveSpacing;
paddingTop?: Spacing | ResponsiveSpacing;
paddingBottom?: Spacing | ResponsiveSpacing;
size?: string | number | ResponsiveValue<string | number>;
accept?: string;
autoPlay?: boolean;
loop?: boolean;
muted?: boolean;
playsInline?: boolean;
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
}
export const Box = forwardRef(<T extends ElementType = 'div'>(
@@ -92,26 +207,126 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
p, pt, pb, pl, pr, px, py,
w, h, width, height,
maxWidth, minWidth, maxHeight, minHeight,
fullWidth, fullHeight,
aspectRatio,
display,
center,
overflow, overflowX, overflowY,
textAlign,
visibility,
position,
top, right, bottom, left,
inset, insetY, insetX,
zIndex,
rounded,
border,
borderTop,
borderBottom,
borderLeft,
borderRight,
borderWidth,
borderStyle,
borderColor,
borderOpacity,
bg,
backgroundColor,
backgroundImage,
backgroundSize,
backgroundPosition,
bgOpacity,
color,
shadow,
opacity,
blur,
pointerEvents,
flex,
flexShrink,
flexGrow,
flexDirection,
flexWrap,
alignItems,
justifyContent,
alignSelf,
gap,
gridCols,
responsiveGridCols,
colSpan,
responsiveColSpan,
order,
transform,
translate,
translateX,
translateY,
initial,
animate,
exit,
transition,
variants,
whileHover,
whileTap,
onHoverStart,
onHoverEnd,
whileInView,
viewport,
custom,
group,
groupHoverTextColor,
groupHoverScale,
groupHoverOpacity,
groupHoverBorderColor,
hoverBorderColor,
hoverBg,
hoverTextColor,
hoverScale,
clickable,
onMouseEnter,
onMouseLeave,
onClick,
onMouseDown,
onMouseUp,
onMouseMove,
onKeyDown,
onBlur,
onSubmit,
onScroll,
style: styleProp,
id,
role,
tabIndex,
type,
disabled,
cursor,
fontSize,
weight,
fontWeight,
letterSpacing,
lineHeight,
font,
ring,
hideScrollbar,
truncate,
src,
alt,
draggable,
min,
max,
step,
value,
onChange,
placeholder,
title,
padding,
paddingLeft,
paddingRight,
paddingTop,
paddingBottom,
size,
accept,
autoPlay,
loop,
muted,
playsInline,
objectFit,
...props
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<HTMLElement>
@@ -131,14 +346,16 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(`${prefix}-${spacingMap[value.base]}`);
if (value.sm !== undefined) classes.push(`sm:${prefix}-${spacingMap[value.sm]}`);
if (value.md !== undefined) classes.push(`md:${prefix}-${spacingMap[value.md]}`);
if (value.lg !== undefined) classes.push(`lg:${prefix}-${spacingMap[value.lg]}`);
if (value.xl !== undefined) classes.push(`xl:${prefix}-${spacingMap[value.xl]}`);
return classes.join(' ');
}
return `${prefix}-${spacingMap[value]}`;
};
const getResponsiveClasses = (prefix: string, value: string | number | ResponsiveValue<string | number> | undefined) => {
const getResponsiveClasses = (prefix: string, value: any | ResponsiveValue<any> | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
@@ -161,42 +378,111 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
getSpacingClass('mr', mr),
getSpacingClass('mx', mx),
getSpacingClass('my', my),
getSpacingClass('p', p),
getSpacingClass('pt', pt),
getSpacingClass('pb', pb),
getSpacingClass('pl', pl),
getSpacingClass('pr', pr),
getSpacingClass('p', p || padding),
getSpacingClass('pt', pt || paddingTop),
getSpacingClass('pb', pb || paddingBottom),
getSpacingClass('pl', pl || paddingLeft),
getSpacingClass('pr', pr || paddingRight),
getSpacingClass('px', px),
getSpacingClass('py', py),
getResponsiveClasses('w', w),
getResponsiveClasses('h', h),
fullWidth ? 'w-full' : getResponsiveClasses('w', w),
fullHeight ? 'h-full' : getResponsiveClasses('h', h),
getResponsiveClasses('max-w', maxWidth),
getResponsiveClasses('min-w', minWidth),
getResponsiveClasses('max-h', maxHeight),
getResponsiveClasses('min-h', minHeight),
getResponsiveClasses('', display),
rounded ? `rounded-${rounded}` : '',
border ? 'border' : '',
center ? 'flex items-center justify-center' : '',
overflow ? (overflow.includes(':') ? overflow : `overflow-${overflow}`) : '',
overflowX ? `overflow-x-${overflowX}` : '',
overflowY ? `overflow-y-${overflowY}` : '',
textAlign ? `text-${textAlign}` : '',
visibility ? visibility : '',
position ? position : '',
getResponsiveClasses('top', top),
getResponsiveClasses('right', right),
getResponsiveClasses('bottom', bottom),
getResponsiveClasses('left', left),
inset !== undefined ? `inset-${inset}` : '',
insetY !== undefined ? `inset-y-${insetY}` : '',
insetX !== undefined ? `inset-x-${insetX}` : '',
zIndex !== undefined ? `z-${zIndex}` : '',
rounded === true ? 'rounded' : (rounded === false ? 'rounded-none' : (typeof rounded === 'string' ? (rounded.includes('-') ? rounded : `rounded-${rounded}`) : '')),
border === true ? 'border' : (typeof border === 'string' ? (border === 'none' ? 'border-none' : border) : ''),
borderTop === true ? 'border-t' : (typeof borderTop === 'string' ? borderTop : ''),
borderBottom === true ? 'border-b' : (typeof borderBottom === 'string' ? borderBottom : ''),
borderLeft === true ? 'border-l' : (typeof borderLeft === 'string' ? borderLeft : ''),
borderRight === true ? 'border-r' : (typeof borderRight === 'string' ? borderRight : ''),
borderStyle ? `border-${borderStyle}` : '',
borderColor ? borderColor : '',
borderOpacity !== undefined ? `border-opacity-${borderOpacity * 100}` : '',
bg ? bg : '',
backgroundColor ? backgroundColor : '',
bgOpacity !== undefined ? `bg-opacity-${bgOpacity * 100}` : '',
color ? color : '',
shadow ? shadow : '',
opacity !== undefined ? `opacity-${opacity * 100}` : '',
blur ? (blur === 'none' ? 'blur-none' : `blur-${blur}`) : '',
pointerEvents ? `pointer-events-${pointerEvents}` : '',
flex !== undefined ? `flex-${flex}` : '',
flexShrink !== undefined ? `flex-shrink-${flexShrink}` : '',
flexGrow !== undefined ? `flex-grow-${flexGrow}` : '',
getResponsiveClasses('flex', flexDirection),
flexWrap ? `flex-${flexWrap}` : '',
getResponsiveClasses('items', alignItems),
getResponsiveClasses('justify', justifyContent),
alignSelf !== undefined ? `self-${alignSelf}` : '',
opacity !== undefined ? `opacity-${opacity * 100}` : '',
getResponsiveClasses('gap', gap),
getResponsiveClasses('grid-cols', gridCols || responsiveGridCols),
getResponsiveClasses('col-span', colSpan || responsiveColSpan),
getResponsiveClasses('order', order),
getResponsiveClasses('text', fontSize),
group ? 'group' : '',
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
groupHoverScale ? 'group-hover:scale-105 transition-transform' : '',
groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '',
groupHoverBorderColor ? `group-hover:border-${groupHoverBorderColor}` : '',
hoverBorderColor ? `hover:border-${hoverBorderColor}` : '',
hoverBg ? `hover:bg-${hoverBg}` : '',
hoverTextColor ? `hover:text-${hoverTextColor}` : '',
hoverScale === true ? 'hover:scale-105 transition-transform' : (typeof hoverScale === 'number' ? `hover:scale-${hoverScale} transition-transform` : ''),
clickable ? 'cursor-pointer active:opacity-80 transition-all' : '',
ring ? `ring-${ring}` : '',
hideScrollbar ? 'scrollbar-hide' : '',
truncate ? 'truncate' : '',
transform === true ? 'transform' : (transform === false ? 'transform-none' : ''),
className
].filter(Boolean).join(' ');
const style: React.CSSProperties = {
...(width ? { width } : {}),
...(height ? { height } : {}),
...(typeof width === 'string' || typeof width === 'number' ? { width } : {}),
...(typeof height === 'string' || typeof height === 'number' ? { height } : {}),
...(typeof maxWidth === 'string' ? { maxWidth } : {}),
...(typeof minWidth === 'string' ? { minWidth } : {}),
...(typeof maxHeight === 'string' ? { maxHeight } : {}),
...(typeof minHeight === 'string' ? { minHeight } : {}),
...(aspectRatio ? { aspectRatio } : {}),
...(typeof top === 'string' || typeof top === 'number' ? { top } : {}),
...(typeof right === 'string' || typeof right === 'number' ? { right } : {}),
...(typeof bottom === 'string' || typeof bottom === 'number' ? { bottom } : {}),
...(typeof left === 'string' || typeof left === 'number' ? { left } : {}),
...(borderWidth !== undefined ? { borderWidth } : {}),
...(typeof transform === 'string' ? { transform } : {}),
...(translate ? { translate } : {}),
...(translateX ? { transform: `translateX(${translateX})` } : {}),
...(translateY ? { transform: `translateY(${translateY})` } : {}),
...(cursor ? { cursor } : {}),
...(fontSize && typeof fontSize === 'string' && !fontSize.includes(':') ? { fontSize } : {}),
...(weight ? { fontWeight: weight } : {}),
...(fontWeight ? { fontWeight } : {}),
...(letterSpacing ? { letterSpacing } : {}),
...(lineHeight ? { lineHeight } : {}),
...(font ? { fontFamily: font } : {}),
...(typeof size === 'string' || typeof size === 'number' ? { width: size, height: size } : {}),
...(backgroundImage ? { backgroundImage } : {}),
...(backgroundSize ? { backgroundSize } : {}),
...(backgroundPosition ? { backgroundPosition } : {}),
...(objectFit ? { objectFit } : {}),
...(styleProp || {})
};
@@ -205,12 +491,35 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
ref={ref as React.ForwardedRef<HTMLElement>}
className={classes}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
onKeyDown={onKeyDown}
onBlur={onBlur}
onSubmit={onSubmit}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onScroll={onScroll}
style={style}
id={id}
role={role}
tabIndex={tabIndex}
type={type}
disabled={disabled}
src={src}
alt={alt}
draggable={draggable}
min={min}
max={max}
step={step}
value={value}
onChange={onChange}
placeholder={placeholder}
title={title}
autoPlay={autoPlay}
loop={loop}
muted={muted}
playsInline={playsInline}
{...props}
>
{children}

View File

@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, ElementType } from 'react';
import { Box, BoxProps, ResponsiveValue } from './Box';
/**
@@ -13,32 +13,16 @@ import { Box, BoxProps, ResponsiveValue } from './Box';
* If you need a more specific layout, create a new component in apps/website/components.
*/
export interface GridProps {
children: ReactNode;
export interface GridProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children'> {
children?: ReactNode;
cols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
mdCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
lgCols?: 1 | 2 | 3 | 4 | 5 | 6 | 12;
gap?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 16;
className?: string;
// Spacing
m?: number;
mt?: number;
mb?: number;
ml?: number;
mr?: number;
p?: number;
pt?: number;
pb?: number;
pl?: number;
pr?: number;
px?: number;
py?: number;
// Sizing
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
}
export function Grid({
export function Grid<T extends ElementType = 'div'>({
children,
cols = 1,
mdCols,
@@ -46,7 +30,7 @@ export function Grid({
gap = 4,
className = '',
...props
}: GridProps) {
}: GridProps<T>) {
const colClasses: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Box } from './Box';
import React, { ElementType } from 'react';
import { Box, BoxProps } from './Box';
/**
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS PRIMITIVE.
@@ -12,15 +12,15 @@ import { Box } from './Box';
* If you need a more specific layout, create a new component in apps/website/components.
*/
export interface GridItemProps {
children: React.ReactNode;
export interface GridItemProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children'> {
children?: React.ReactNode;
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
mdSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
lgSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
className?: string;
}
export function GridItem({ children, colSpan, mdSpan, lgSpan, className = '' }: GridItemProps) {
export function GridItem<T extends ElementType = 'div'>({ children, colSpan, mdSpan, lgSpan, className = '', ...props }: GridItemProps<T>) {
const spanClasses = [
colSpan ? `col-span-${colSpan}` : '',
mdSpan ? `md:col-span-${mdSpan}` : '',
@@ -29,7 +29,7 @@ export function GridItem({ children, colSpan, mdSpan, lgSpan, className = '' }:
].filter(Boolean).join(' ');
return (
<Box className={spanClasses}>
<Box className={spanClasses} {...props}>
{children}
</Box>
);

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, ElementType } from 'react';
import React, { ReactNode, ElementType, forwardRef, ForwardedRef } from 'react';
import { Box, BoxProps, ResponsiveValue } from './Box';
/**
@@ -13,8 +13,6 @@ import { Box, BoxProps, ResponsiveValue } from './Box';
* If you need a more specific layout, create a new component in apps/website/components.
*/
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
interface ResponsiveGap {
base?: number;
sm?: number;
@@ -23,73 +21,31 @@ interface ResponsiveGap {
xl?: number;
}
interface ResponsiveSpacing {
base?: Spacing;
sm?: Spacing;
md?: Spacing;
lg?: Spacing;
xl?: Spacing;
'2xl'?: Spacing;
}
export interface StackProps<T extends ElementType> {
export interface StackProps<T extends ElementType> extends Omit<BoxProps<T>, 'children'> {
as?: T;
children: ReactNode;
children?: ReactNode;
className?: string;
direction?: 'row' | 'col' | { base?: 'row' | 'col'; md?: 'row' | 'col'; lg?: 'row' | 'col' };
gap?: number | ResponsiveGap;
gap?: number | string | ResponsiveGap;
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' | ResponsiveValue<'start' | 'center' | 'end' | 'stretch' | 'baseline'>;
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | ResponsiveValue<'start' | 'center' | 'end' | 'between' | 'around'>;
wrap?: boolean;
// Spacing (allowed for layout)
m?: Spacing | ResponsiveSpacing;
mt?: Spacing | ResponsiveSpacing;
mb?: Spacing | ResponsiveSpacing;
ml?: Spacing | ResponsiveSpacing;
mr?: Spacing | ResponsiveSpacing;
p?: Spacing | ResponsiveSpacing;
pt?: Spacing | ResponsiveSpacing;
pb?: Spacing | ResponsiveSpacing;
pl?: Spacing | ResponsiveSpacing;
pr?: Spacing | ResponsiveSpacing;
px?: Spacing | ResponsiveSpacing;
py?: Spacing | ResponsiveSpacing;
// Sizing (allowed for layout)
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
minWidth?: string | ResponsiveValue<string>;
maxWidth?: string | ResponsiveValue<string>;
minHeight?: string | ResponsiveValue<string>;
maxHeight?: string | ResponsiveValue<string>;
// Basic styling (sometimes needed for containers)
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
// Flex item props
flex?: number | string;
flexGrow?: number;
flexShrink?: number;
alignSelf?: 'auto' | 'start' | 'end' | 'center' | 'stretch' | 'baseline';
style?: React.CSSProperties;
}
export function Stack<T extends ElementType = 'div'>({
children,
className = '',
direction = 'col',
gap = 4,
align,
justify,
wrap = false,
m, mt, mb, ml, mr,
p, pt, pb, pl, pr, px, py,
w, h, minWidth, maxWidth, minHeight, maxHeight,
rounded,
flex,
flexGrow,
flexShrink,
alignSelf,
as,
...props
}: StackProps<T>) {
export const Stack = forwardRef(<T extends ElementType = 'div'>(
{
children,
className = '',
direction = 'col',
gap = 4,
align,
justify,
wrap = false,
as,
...props
}: StackProps<T>,
ref: ForwardedRef<HTMLElement>
) => {
const gapClasses: Record<number, string> = {
0: 'gap-0',
1: 'gap-1',
@@ -104,50 +60,19 @@ export function Stack<T extends ElementType = 'div'>({
16: 'gap-16'
};
const getGapClasses = (value: number | ResponsiveGap | undefined) => {
const getGapClasses = (value: number | string | ResponsiveGap | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(gapClasses[value.base]);
if (value.sm !== undefined) classes.push(`sm:${gapClasses[value.sm]}`);
if (value.md !== undefined) classes.push(`md:${gapClasses[value.md]}`);
if (value.lg !== undefined) classes.push(`lg:${gapClasses[value.lg]}`);
if (value.xl !== undefined) classes.push(`xl:${gapClasses[value.xl]}`);
if (value.base !== undefined) classes.push(typeof value.base === 'number' ? gapClasses[value.base] : `gap-${value.base}`);
if (value.sm !== undefined) classes.push(typeof value.sm === 'number' ? `sm:${gapClasses[value.sm]}` : `sm:gap-${value.sm}`);
if (value.md !== undefined) classes.push(typeof value.md === 'number' ? `md:${gapClasses[value.md]}` : `md:gap-${value.md}`);
if (value.lg !== undefined) classes.push(typeof value.lg === 'number' ? `lg:${gapClasses[value.lg]}` : `lg:gap-${value.lg}`);
if (value.xl !== undefined) classes.push(typeof value.xl === 'number' ? `xl:${gapClasses[value.xl]}` : `xl:gap-${value.xl}`);
return classes.join(' ');
}
return gapClasses[value];
};
const spacingMap: Record<number, string> = {
0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4',
5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: '11', 12: '12', 14: '14',
16: '16', 20: '20', 24: '24', 28: '28', 32: '32', 36: '36', 40: '40', 44: '44',
48: '48', 52: '52', 56: '56', 60: '60', 64: '64', 72: '72', 80: '80', 96: '96'
};
const roundedClasses = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
full: 'rounded-full'
};
const getSpacingClass = (prefix: string, value: Spacing | ResponsiveSpacing | undefined) => {
if (value === undefined) return '';
if (typeof value === 'object') {
const classes = [];
if (value.base !== undefined) classes.push(`${prefix}-${spacingMap[value.base]}`);
if (value.sm !== undefined) classes.push(`sm:${prefix}-${spacingMap[value.sm]}`);
if (value.md !== undefined) classes.push(`md:${prefix}-${spacingMap[value.md]}`);
if (value.lg !== undefined) classes.push(`lg:${prefix}-${spacingMap[value.lg]}`);
if (value.xl !== undefined) classes.push(`xl:${prefix}-${spacingMap[value.xl]}`);
if (value['2xl'] !== undefined) classes.push(`2xl:${prefix}-${spacingMap[value['2xl']]}`);
return classes.join(' ');
}
return `${prefix}-${spacingMap[value]}`;
if (typeof value === 'number') return gapClasses[value];
return `gap-${value}`;
};
const classes = [
@@ -161,19 +86,6 @@ export function Stack<T extends ElementType = 'div'>({
].filter(Boolean).join(' '),
getGapClasses(gap) || 'gap-4',
wrap ? 'flex-wrap' : '',
getSpacingClass('m', m),
getSpacingClass('mt', mt),
getSpacingClass('mb', mb),
getSpacingClass('ml', ml),
getSpacingClass('mr', mr),
getSpacingClass('p', p),
getSpacingClass('pt', pt),
getSpacingClass('pb', pb),
getSpacingClass('pl', pl),
getSpacingClass('pr', pr),
getSpacingClass('px', px),
getSpacingClass('py', py),
rounded ? roundedClasses[rounded] : '',
className
].filter(Boolean).join(' ');
@@ -217,20 +129,13 @@ export function Stack<T extends ElementType = 'div'>({
return (
<Box
as={as}
ref={ref}
className={`${classes} ${layoutClasses}`}
w={w}
h={h}
minWidth={minWidth}
maxWidth={maxWidth}
minHeight={minHeight}
maxHeight={maxHeight}
flex={flex}
flexGrow={flexGrow}
flexShrink={flexShrink}
alignSelf={alignSelf}
{...props}
>
{children}
</Box>
);
}
});
Stack.displayName = 'Stack';

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, ElementType, ComponentPropsWithoutRef } from 'react';
import { Box, BoxProps, ResponsiveValue } from './Box';
import React, { ReactNode, ElementType, ComponentPropsWithoutRef, forwardRef, ForwardedRef } from 'react';
import { Box, BoxProps } from './Box';
/**
* WARNING: DO NOT VIOLATE THE PURPOSE OF THIS PRIMITIVE.
@@ -12,33 +12,31 @@ import { Box, BoxProps, ResponsiveValue } from './Box';
* If you need a more specific layout, create a new component in apps/website/components.
*/
export interface SurfaceProps<T extends ElementType = 'div'> {
export interface SurfaceProps<T extends ElementType = 'div'> extends Omit<BoxProps<T>, 'children' | 'padding'> {
as?: T;
children: ReactNode;
children?: ReactNode;
variant?: 'default' | 'muted' | 'dark' | 'glass' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord' | 'discord-inner';
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full' | string | boolean;
border?: boolean | string;
padding?: number;
className?: string;
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'discord' | string;
// Sizing
w?: string | ResponsiveValue<string>;
h?: string | ResponsiveValue<string>;
maxWidth?: string | ResponsiveValue<string>;
}
export function Surface<T extends ElementType = 'div'>({
as,
children,
variant = 'default',
rounded = 'none',
border = false,
padding = 0,
className = '',
shadow = 'none',
w, h, maxWidth,
...props
}: SurfaceProps<T> & ComponentPropsWithoutRef<T>) {
export const Surface = forwardRef(<T extends ElementType = 'div'>(
{
as,
children,
variant = 'default',
rounded = 'none',
border = false,
padding = 0,
className = '',
shadow = 'none',
...props
}: SurfaceProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<HTMLElement>
) => {
const variantClasses: Record<string, string> = {
default: 'bg-panel-gray',
muted: 'bg-panel-gray/40',
@@ -85,7 +83,7 @@ export function Surface<T extends ElementType = 'div'>({
const classes = [
variantClasses[variant],
roundedClasses[rounded],
typeof rounded === 'string' && roundedClasses[rounded] ? roundedClasses[rounded] : '',
border ? 'border border-border-gray' : '',
paddingClasses[padding] || 'p-0',
shadowClasses[shadow],
@@ -93,8 +91,10 @@ export function Surface<T extends ElementType = 'div'>({
].filter(Boolean).join(' ');
return (
<Box as={as} className={classes} w={w} h={h} maxWidth={maxWidth} {...props}>
<Box as={as} ref={ref} className={classes} {...props}>
{children}
</Box>
);
}
});
Surface.displayName = 'Surface';