mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 19:49:25 +01:00
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>
214 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|