project-nomad/admin/inertia/components/Alert.tsx
Chris Sherwood c0da3f0198 feat(UI): add Night Ops dark mode with theme toggle
Add a warm charcoal dark mode ("Night Ops") using CSS variable swapping
under [data-theme="dark"]. All 23 desert palette variables are overridden
with dark-mode counterparts, and ~313 generic Tailwind classes (bg-white,
text-gray-*, border-gray-*) are replaced with semantic tokens.

Infrastructure:
- CSS variable overrides in app.css for both themes
- ThemeProvider + useTheme hook (localStorage + KV store sync)
- ThemeToggle component (moon/sun icons, "Night Ops"/"Day Ops" labels)
- FOUC prevention script in inertia_layout.edge
- Toggle placed in StyledSidebar and Footer for access on every page

Color replacements across 50 files:
- bg-white → bg-surface-primary
- bg-gray-50/100 → bg-surface-secondary
- text-gray-900/800 → text-text-primary
- text-gray-600/500 → text-text-secondary/text-text-muted
- border-gray-200/300 → border-border-subtle/border-border-default
- text-desert-white → text-white (fixes invisible text on colored bg)
- Button hover/active states use dedicated btn-green-hover/active vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:42:07 -07:00

214 lines
7.0 KiB
TypeScript

import * as Icons from '@tabler/icons-react'
import classNames from '~/lib/classNames'
import DynamicIcon from './DynamicIcon'
import StyledButton, { StyledButtonProps } from './StyledButton'
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
title: string
message?: string
type: 'warning' | 'error' | 'success' | 'info' | 'info-inverted'
children?: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
icon?: keyof typeof Icons
variant?: 'standard' | 'bordered' | 'solid'
buttonProps?: StyledButtonProps
}
export default function Alert({
title,
message,
type,
children,
dismissible = false,
onDismiss,
icon,
variant = 'standard',
buttonProps,
...props
}: AlertProps) {
const getDefaultIcon = (): keyof typeof Icons => {
switch (type) {
case 'warning':
return 'IconAlertTriangle'
case 'error':
return 'IconXboxX'
case 'success':
return 'IconCircleCheck'
case 'info':
return 'IconInfoCircle'
default:
return 'IconInfoCircle'
}
}
const getIconColor = () => {
if (variant === 'solid') return 'text-white'
switch (type) {
case 'warning':
return 'text-desert-orange'
case 'error':
return 'text-desert-red'
case 'success':
return 'text-desert-olive'
case 'info':
return 'text-desert-stone'
default:
return 'text-desert-stone'
}
}
const getVariantStyles = () => {
const baseStyles = 'rounded-lg transition-all duration-200'
const variantStyles: string[] = []
switch (variant) {
case 'bordered':
variantStyles.push(
type === 'warning'
? 'border-desert-orange'
: type === 'error'
? 'border-desert-red'
: type === 'success'
? 'border-desert-olive'
: type === 'info'
? 'border-desert-stone'
: type === 'info-inverted'
? 'border-desert-tan'
: ''
)
return classNames(baseStyles, 'border-2 bg-desert-white shadow-md', ...variantStyles)
case 'solid':
variantStyles.push(
type === 'warning'
? 'bg-desert-orange text-white border border-desert-orange-dark'
: type === 'error'
? 'bg-desert-red text-white border border-desert-red-dark'
: type === 'success'
? 'bg-desert-olive text-white border border-desert-olive-dark'
: type === 'info'
? 'bg-desert-green text-white border border-desert-green-dark'
: type === 'info-inverted'
? 'bg-desert-tan text-white border border-desert-tan-dark'
: ''
)
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
default:
variantStyles.push(
type === 'warning'
? 'bg-desert-orange-lighter bg-opacity-20 border-desert-orange-light'
: type === 'error'
? 'bg-desert-red-lighter bg-opacity-20 border-desert-red-light'
: type === 'success'
? 'bg-desert-olive-lighter bg-opacity-20 border-desert-olive-light'
: type === 'info'
? 'bg-desert-green bg-opacity-20 border-desert-green-light'
: type === 'info-inverted'
? 'bg-desert-tan bg-opacity-20 border-desert-tan-light'
: ''
)
return classNames(baseStyles, 'border-l-4 border-y border-r shadow-sm', ...variantStyles)
}
}
const getTitleColor = () => {
if (variant === 'solid') return 'text-white'
switch (type) {
case 'warning':
return 'text-desert-orange-dark'
case 'error':
return 'text-desert-red-dark'
case 'success':
return 'text-desert-olive-dark'
case 'info':
return 'text-desert-stone-dark'
case 'info-inverted':
return 'text-desert-tan-dark'
default:
return 'text-desert-stone-dark'
}
}
const getMessageColor = () => {
if (variant === 'solid') return 'text-white text-opacity-90'
switch (type) {
case 'warning':
return 'text-desert-orange-dark text-opacity-80'
case 'error':
return 'text-desert-red-dark text-opacity-80'
case 'success':
return 'text-desert-olive-dark text-opacity-80'
case 'info':
return 'text-desert-stone-dark text-opacity-80'
default:
return 'text-desert-stone-dark text-opacity-80'
}
}
const getCloseButtonStyles = () => {
if (variant === 'solid') {
return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
}
switch (type) {
case 'warning':
return 'text-desert-orange hover:text-desert-orange-dark hover:bg-desert-orange-lighter hover:bg-opacity-30'
case 'error':
return 'text-desert-red hover:text-desert-red-dark hover:bg-desert-red-lighter hover:bg-opacity-30'
case 'success':
return 'text-desert-olive hover:text-desert-olive-dark hover:bg-desert-olive-lighter hover:bg-opacity-30'
case 'info':
return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'
default:
return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'
}
}
return (
<div {...props} className={classNames(getVariantStyles(), 'p-5', props.className)} role="alert">
<div className="flex gap-4 items-center">
<div className="flex-shrink-0 mt-0.5">
<DynamicIcon icon={icon || getDefaultIcon()} className={classNames(getIconColor(), 'size-6')} />
</div>
<div className="flex-1 min-w-0">
<h3 className={classNames('text-base font-semibold leading-tight', getTitleColor())}>{title}</h3>
{message && (
<div className={classNames('mt-2 text-sm leading-relaxed', getMessageColor())}>
<p>{message}</p>
</div>
)}
{children && <div className="mt-3">{children}</div>}
</div>
{buttonProps && (
<div className="flex-shrink-0 ml-auto">
<StyledButton {...buttonProps} />
</div>
)}
{dismissible && (
<button
type="button"
onClick={onDismiss}
className={classNames(
'flex-shrink-0 rounded-lg p-1.5 transition-all duration-200',
getCloseButtonStyles(),
'focus:outline-none focus:ring-2 focus:ring-offset-1',
type === 'warning' ? 'focus:ring-desert-orange' : '',
type === 'error' ? 'focus:ring-desert-red' : '',
type === 'success' ? 'focus:ring-desert-olive' : '',
type === 'info' ? 'focus:ring-desert-stone' : ''
)}
aria-label="Dismiss alert"
>
<DynamicIcon icon="IconX" className="size-4" />
</button>
)}
</div>
</div>
)
}