diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 767fa918a..d7d9560a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; import { ViewerProvider } from "./contexts/ViewerContext"; +import ToastPlayground from "./components/toast/ToastPlayground"; // Import file ID debugging helpers (development only) import "./utils/fileIdSafety"; @@ -47,6 +48,7 @@ export default function App() { + {import.meta.env.DEV && } diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index f03404eac..5ba24b513 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; import { FileId, StirlingFile } from '../../types/fileContext'; +import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; @@ -48,6 +49,14 @@ const FileEditor = ({ const [status, setStatus] = useState(null); const [error, setError] = useState(null); + + // Toast helpers + const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { + alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); + }, []); + const showError = useCallback((message: string) => { + alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); + }, []); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode @@ -157,18 +166,18 @@ const FileEditor = ({ // Show any errors if (errors.length > 0) { - setError(errors.join('\n')); + showError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); - setStatus(`Added ${allExtractedFiles.length} files`); + showStatus(`Added ${allExtractedFiles.length} files`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; - setError(errorMessage); + showError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error @@ -206,7 +215,7 @@ const FileEditor = ({ } else { // Check if we've hit the selection limit if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - setStatus(`Maximum ${maxAllowed} files can be selected`); + showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); return; } newSelection = [...currentSelectedIds, contextFileId]; @@ -271,7 +280,7 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); }, [activeStirlingFileStubs, reorderFiles, setStatus]); @@ -314,10 +323,10 @@ const FileEditor = ({ try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files - setStatus(`Loaded ${selectedFiles.length} files from storage`); + showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); - setError('Failed to load some files from storage'); + showError('Failed to load some files from storage'); } }, []); @@ -408,7 +417,7 @@ const FileEditor = ({ onToggleFile={toggleFile} onDeleteFile={handleDeleteFile} onViewFile={handleViewFile} - onSetStatus={setStatus} + onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} toolMode={toolMode} @@ -428,31 +437,7 @@ const FileEditor = ({ onSelectFiles={handleLoadFromStorage} /> - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }} - > - {status} - - - )} - - {error && ( - - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} - > - {error} - - - )} + ); diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index f28713c73..a933ddf1c 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -263,10 +264,10 @@ const FileEditorThumbnail = ({ if (actualFile) { if (isPinned) { unpinFile(actualFile); - onSetStatus?.(`Unpinned ${file.name}`); + alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); } else { pinFile(actualFile); - onSetStatus?.(`Pinned ${file.name}`); + alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); } } setShowActions(false); @@ -278,7 +279,7 @@ const FileEditorThumbnail = ({ { onDownloadFile(file.id); setShowActions(false); }} + onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }} > {t('download', 'Download')} @@ -290,7 +291,7 @@ const FileEditorThumbnail = ({ className={`${styles.actionRow} ${styles.actionDanger}`} onClick={() => { onDeleteFile(file.id); - onSetStatus(`Deleted ${file.name}`); + alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 }); setShowActions(false); }} > diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx index 21c46cf72..e452538fb 100644 --- a/frontend/src/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core'; import { useRainbowTheme } from '../../hooks/useRainbowTheme'; import { mantineTheme } from '../../theme/mantineTheme'; import rainbowStyles from '../../styles/rainbow.module.css'; +import { ToastProvider } from '../toast'; +import ToastRenderer from '../toast/ToastRenderer'; +import { ToastPortalBinder } from '../toast'; interface RainbowThemeContextType { themeMode: 'light' | 'dark' | 'rainbow'; @@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''} style={{ minHeight: '100vh' }} > - {children} + + + {children} + + diff --git a/frontend/src/components/toast/ToastContext.tsx b/frontend/src/components/toast/ToastContext.tsx new file mode 100644 index 000000000..668c1df9e --- /dev/null +++ b/frontend/src/components/toast/ToastContext.tsx @@ -0,0 +1,137 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react'; +import { ToastApi, ToastInstance, ToastOptions } from './types'; + +function normalizeProgress(value: number | undefined): number | undefined { + if (typeof value !== 'number' || Number.isNaN(value)) return undefined; + // Accept 0..1 as fraction or 0..100 as percent + if (value <= 1) return Math.max(0, Math.min(1, value)) * 100; + return Math.max(0, Math.min(100, value)); +} + +function generateId() { + return `toast_${Math.random().toString(36).slice(2, 9)}`; +} + +type DefaultOpts = Required> & + Partial>; + +const defaultOptions: DefaultOpts = { + alertType: 'neutral', + title: '', + isPersistentPopup: false, + location: 'bottom-right', + durationMs: 6000, +}; + +interface ToastContextShape extends ToastApi { + toasts: ToastInstance[]; +} + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timers = useRef>({}); + + const scheduleAutoDismiss = useCallback((toast: ToastInstance) => { + if (toast.isPersistentPopup) return; + window.clearTimeout(timers.current[toast.id]); + timers.current[toast.id] = window.setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== toast.id)); + }, toast.durationMs); + }, []); + + const show = useCallback((options) => { + const id = options.id || generateId(); + const merged: ToastInstance = { + ...defaultOptions, + ...options, + id, + progress: normalizeProgress(options.progressBarPercentage), + justCompleted: false, + expandable: options.expandable !== false, + isExpanded: options.expandable === false ? true : false, + createdAt: Date.now(), + } as ToastInstance; + setToasts(prev => { + const next = [...prev.filter(t => t.id !== id), merged]; + return next; + }); + scheduleAutoDismiss(merged); + return id; + }, [scheduleAutoDismiss]); + + const update = useCallback((id, updates) => { + setToasts(prev => prev.map(t => { + if (t.id !== id) return t; + const progress = updates.progressBarPercentage !== undefined + ? normalizeProgress(updates.progressBarPercentage) + : t.progress; + + const next: ToastInstance = { + ...t, + ...updates, + progress, + } as ToastInstance; + + // Detect completion + if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) { + // On completion: finalize type as success unless explicitly provided otherwise + next.justCompleted = false; + if (!updates.alertType) { + next.alertType = 'success'; + } + } + + return next; + })); + }, []); + + const updateProgress = useCallback((id, progress) => { + update(id, { progressBarPercentage: progress }); + }, [update]); + + const dismiss = useCallback((id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + window.clearTimeout(timers.current[id]); + delete timers.current[id]; + }, []); + + const dismissAll = useCallback(() => { + setToasts([]); + Object.values(timers.current).forEach(t => window.clearTimeout(t)); + timers.current = {}; + }, []); + + const value = useMemo(() => ({ + toasts, + show, + update, + updateProgress, + dismiss, + dismissAll, + }), [toasts, show, update, updateProgress, dismiss, dismissAll]); + + // Handle expand/collapse toggles from renderer without widening API + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { id: string } | undefined; + if (!detail?.id) return; + setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t)); + }; + window.addEventListener('toast:toggle', handler as EventListener); + return () => window.removeEventListener('toast:toggle', handler as EventListener); + }, []); + + return ( + {children} + ); +} + + diff --git a/frontend/src/components/toast/ToastPlayground.tsx b/frontend/src/components/toast/ToastPlayground.tsx new file mode 100644 index 000000000..5d52fa7a8 --- /dev/null +++ b/frontend/src/components/toast/ToastPlayground.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { alert, updateToastProgress, updateToast, dismissToast, dismissAllToasts } from './index'; + +function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export default function ToastPlayground() { + const runProgress = async () => { + const id = alert({ + alertType: 'neutral', + title: 'Downloading…', + body: 'Fetching data from server', + progressBarPercentage: 0, + isPersistentPopup: true, + location: 'bottom-right', + }); + for (let p = 0; p <= 100; p += 10) { + updateToastProgress(id, p); + // eslint-disable-next-line no-await-in-loop + await wait(250); + } + updateToast(id, { title: 'Download complete', body: 'File saved', isPersistentPopup: false, alertType: 'success' }); + setTimeout(() => dismissToast(id), 2000); + }; + + const withButtons = () => { + alert({ + alertType: 'warning', + title: 'Replace existing file?', + body: 'A file with the same name already exists.', + buttonText: 'Replace', + buttonCallback: () => alert({ alertType: 'success', title: 'Replaced', body: 'Your file has been replaced.' }), + isPersistentPopup: true, + location: 'top-right', + }); + }; + + const withCustomIcon = () => { + alert({ + alertType: 'neutral', + title: 'Custom icon', + body: 'This toast shows a custom SVG icon.', + icon: ( + + + + + + ), + isPersistentPopup: false, + location: 'top-left', + }); + }; + + const differentLocations = () => { + (['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).forEach((loc) => { + alert({ alertType: 'neutral', title: `Toast @ ${loc}`, body: 'Location test', location: loc }); + }); + }; + + const success = () => alert({ alertType: 'success', title: 'Success', body: 'Operation completed.' }); + const error = () => alert({ alertType: 'error', title: 'Error', body: 'Something went wrong.' }); + const warning = () => alert({ alertType: 'warning', title: 'Warning', body: 'Please check your inputs.' }); + const neutral = () => alert({ alertType: 'neutral', title: 'Information', body: 'Heads up!' }); + + const persistent = () => alert({ alertType: 'neutral', title: 'Persistent toast', body: 'Click × to close.', isPersistentPopup: true }); + + return ( + + + Success + Error + Warning + Neutral + + With button + Custom icon + All locations + Progress demo + Persistent + + dismissAllToasts()}>Dismiss all + + + ); +} + +function Button({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Divider() { + return ; +} + + diff --git a/frontend/src/components/toast/ToastRenderer.tsx b/frontend/src/components/toast/ToastRenderer.tsx new file mode 100644 index 000000000..a525abcc7 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { useToast } from './ToastContext'; +import { ToastInstance, ToastLocation } from './types'; +import { LocalIcon } from '../shared/LocalIcon'; + +const locationToClass: Record = { + 'top-left': { top: 16, left: 16, flexDirection: 'column' }, + 'top-right': { top: 16, right: 16, flexDirection: 'column' }, + 'bottom-left': { bottom: 16, left: 16, flexDirection: 'column-reverse' }, + 'bottom-right': { bottom: 16, right: 16, flexDirection: 'column-reverse' }, +}; + +function getColors(t: ToastInstance) { + switch (t.alertType) { + case 'success': + return { bg: 'var(--color-green-100)', border: 'var(--color-green-400)', text: 'var(--text-primary)', bar: 'var(--color-green-500)' }; + case 'error': + return { bg: 'var(--color-red-100)', border: 'var(--color-red-400)', text: 'var(--text-primary)', bar: 'var(--color-red-500)' }; + case 'warning': + return { bg: 'var(--color-yellow-100)', border: 'var(--color-yellow-400)', text: 'var(--text-primary)', bar: 'var(--color-yellow-500)' }; + case 'neutral': + default: + return { bg: 'var(--bg-surface)', border: 'var(--border-default)', text: 'var(--text-primary)', bar: 'var(--color-gray-500)' }; + } +} + +function getDefaultIconName(t: ToastInstance): string { + switch (t.alertType) { + case 'success': + return 'check-circle-rounded'; + case 'error': + return 'close-rounded'; + case 'warning': + return 'warning-rounded'; + case 'neutral': + default: + return 'info-rounded'; + } +} + +export default function ToastRenderer() { + const { toasts, dismiss } = useToast(); + + const grouped = toasts.reduce>((acc, t) => { + const key = t.location; + if (!acc[key]) acc[key] = [] as ToastInstance[]; + acc[key].push(t); + return acc; + }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] }); + + return ( + <> + {(Object.keys(grouped) as ToastLocation[]).map((loc) => ( + + {grouped[loc].map(t => { + const colors = getColors(t); + return ( + + {/* Top row: Icon + Title + Controls */} + + {/* Icon */} + + {t.icon ?? ( + + )} + + + {/* Title */} + {t.title} + + {/* Controls */} + + {t.expandable && ( + { + const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } }); + window.dispatchEvent(evt); + }} + style={{ + width: 28, + height: 28, + borderRadius: 999, + border: 'none', + background: 'transparent', + color: 'var(--text-secondary)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transform: t.isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', + transition: 'transform 160ms ease', + }} + > + + + )} + dismiss(t.id)} + style={{ + width: 28, + height: 28, + borderRadius: 999, + border: 'none', + background: 'transparent', + color: 'var(--text-secondary)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + + + + + {(t.isExpanded || !t.expandable) && ( + + {t.body} + {t.buttonText && t.buttonCallback && ( + + {t.buttonText} + + )} + {typeof t.progress === 'number' && ( + + + + )} + + )} + + ); + })} + + ))} + > + ); +} + + diff --git a/frontend/src/components/toast/index.ts b/frontend/src/components/toast/index.ts new file mode 100644 index 000000000..d0b1045f2 --- /dev/null +++ b/frontend/src/components/toast/index.ts @@ -0,0 +1,61 @@ +import { ToastOptions } from './types'; +import { useToast, ToastProvider } from './ToastContext'; +import ToastRenderer from './ToastRenderer'; + +export { useToast, ToastProvider, ToastRenderer }; + +// Global imperative API via module singleton +let _api: ReturnType | null = null; + +function createImperativeApi() { + const subscribers: Array<(fn: any) => void> = []; + let api: any = null; + return { + provide(instance: any) { + api = instance; + subscribers.splice(0).forEach(cb => cb(api)); + }, + get(): any | null { return api; }, + onReady(cb: (api: any) => void) { + if (api) cb(api); else subscribers.push(cb); + } + }; +} + +if (!_api) _api = createImperativeApi(); + +// Hook helper to wire context API back to singleton +export function ToastPortalBinder() { + const ctx = useToast(); + // Provide API once mounted + _api!.provide(ctx); + return null; +} + +export function alert(options: ToastOptions) { + if (_api?.get()) { + return _api.get()!.show(options); + } + // Queue until provider mounts + let id = ''; + _api?.onReady((api) => { id = api.show(options); }); + return id; +} + +export function updateToast(id: string, options: Partial) { + _api?.get()?.update(id, options); +} + +export function updateToastProgress(id: string, progress: number) { + _api?.get()?.updateProgress(id, progress); +} + +export function dismissToast(id: string) { + _api?.get()?.dismiss(id); +} + +export function dismissAllToasts() { + _api?.get()?.dismissAll(); +} + + diff --git a/frontend/src/components/toast/types.ts b/frontend/src/components/toast/types.ts new file mode 100644 index 000000000..a3071e80f --- /dev/null +++ b/frontend/src/components/toast/types.ts @@ -0,0 +1,48 @@ +import { ReactNode } from 'react'; + +export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral'; + +export interface ToastOptions { + alertType?: ToastAlertType; + title: string; + body?: ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: ToastLocation; + icon?: ReactNode; + /** number 0-1 as fraction or 0-100 as percent */ + progressBarPercentage?: number; + /** milliseconds to auto-close if not persistent */ + durationMs?: number; + /** optional id to control/update later */ + id?: string; + /** If true, show chevron and collapse/expand animation. Defaults to true. */ + expandable?: boolean; +} + +export interface ToastInstance extends Omit { + id: string; + alertType: ToastAlertType; + isPersistentPopup: boolean; + location: ToastLocation; + durationMs: number; + expandable: boolean; + isExpanded: boolean; + /** internal progress normalized 0..100 */ + progress?: number; + /** if progress completed, briefly show check icon */ + justCompleted: boolean; + createdAt: number; +} + +export interface ToastApi { + show: (options: ToastOptions) => string; + update: (id: string, options: Partial) => void; + updateProgress: (id: string, progress: number) => void; + dismiss: (id: string) => void; + dismissAll: () => void; +} + + diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index ae282ebaf..e71771573 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from 'react'; -import axios, { CancelTokenSource } from 'axios'; +import axios, { CancelTokenSource } from '../../../services/http'; import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor'; import type { ProcessingProgress } from './useToolState'; diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7735dd1a4..896081fbc 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,5 +1,5 @@ import { useCallback, useRef, useEffect } from 'react'; -import axios from 'axios'; +import axios from '../../../services/http'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 000000000..fd1812932 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,69 @@ +import axios from 'axios'; +import { alert } from '../components/toast'; + +function extractAxiosErrorMessage(error: any): string { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const statusText = error.response?.statusText || 'Request Error'; + const body = ((): string => { + const data = error.response?.data as any; + if (!data) return ''; + if (typeof data === 'string') return data; + if (data?.message) return data.message as string; + try { return JSON.stringify(data); } catch { return ''; } + })(); + return `${status ?? ''} ${statusText}${body ? `: ${body}` : ''}`.trim(); + } + try { + return (error?.message || String(error)) as string; + } catch { + return 'Unknown network error'; + } +} + +// Install Axios response error interceptor +axios.interceptors.response.use( + (response) => response, + (error) => { + const msg = extractAxiosErrorMessage(error); + alert({ + alertType: 'error', + title: 'Request failed', + body: msg, + expandable: true, + isPersistentPopup: false, + }); + return Promise.reject(error); + } +); + +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); + if (!res.ok) { + let detail = ''; + try { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const data = await res.json(); + detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data)); + } else { + detail = await res.text(); + } + } catch { + // ignore parse errors + } + alert({ + alertType: 'error', + title: `Request failed (${res.status})`, + body: detail || res.statusText, + expandable: true, + isPersistentPopup: false, + }); + } + return res; +} + +export default axios; +export type { CancelTokenSource } from 'axios'; + + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index f5e5e91bf..4d416cf09 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -30,6 +30,30 @@ --color-primary-800: #1e40af; --color-primary-900: #1e3a8a; + /* Success (green) */ + --color-green-50: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #bbf7d0; + --color-green-300: #86efac; + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #15803d; + --color-green-800: #166534; + --color-green-900: #14532d; + + /* Warning (yellow) */ + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-300: #fde047; + --color-yellow-400: #facc15; + --color-yellow-500: #eab308; + --color-yellow-600: #ca8a04; + --color-yellow-700: #a16207; + --color-yellow-800: #854d0e; + --color-yellow-900: #713f12; + --color-red-50: #fef2f2; --color-red-100: #fee2e2; --color-red-200: #fecaca; @@ -241,6 +265,30 @@ --color-gray-800: #e5e7eb; --color-gray-900: #f3f4f6; + /* Success (green) - dark */ + --color-green-50: #052e16; + --color-green-100: #064e3b; + --color-green-200: #065f46; + --color-green-300: #047857; + --color-green-400: #059669; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #4ade80; + --color-green-800: #86efac; + --color-green-900: #bbf7d0; + + /* Warning (yellow) - dark */ + --color-yellow-50: #451a03; + --color-yellow-100: #713f12; + --color-yellow-200: #854d0e; + --color-yellow-300: #a16207; + --color-yellow-400: #ca8a04; + --color-yellow-500: #eab308; + --color-yellow-600: #facc15; + --color-yellow-700: #fde047; + --color-yellow-800: #fef08a; + --color-yellow-900: #fef9c3; + /* Dark theme semantic colors */ --bg-surface: #2A2F36; --bg-raised: #1F2329; diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index b91bbe83a..0e43db9a4 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [ 'var(--color-primary-900)', ]; +const green: MantineColorsTuple = [ + 'var(--color-green-50)', + 'var(--color-green-100)', + 'var(--color-green-200)', + 'var(--color-green-300)', + 'var(--color-green-400)', + 'var(--color-green-500)', + 'var(--color-green-600)', + 'var(--color-green-700)', + 'var(--color-green-800)', + 'var(--color-green-900)', +]; + +const yellow: MantineColorsTuple = [ + 'var(--color-yellow-50)', + 'var(--color-yellow-100)', + 'var(--color-yellow-200)', + 'var(--color-yellow-300)', + 'var(--color-yellow-400)', + 'var(--color-yellow-500)', + 'var(--color-yellow-600)', + 'var(--color-yellow-700)', + 'var(--color-yellow-800)', + 'var(--color-yellow-900)', +]; + const gray: MantineColorsTuple = [ 'var(--color-gray-50)', 'var(--color-gray-100)', @@ -34,6 +60,8 @@ export const mantineTheme = createTheme({ // Color palette colors: { primary, + green, + yellow, gray, }, diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 42d04d16d..405c2bbc1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -22,6 +22,42 @@ module.exports = { 800: 'rgb(var(--gray-800) / )', 900: 'rgb(var(--gray-900) / )', }, + green: { + 50: 'var(--color-green-50)', + 100: 'var(--color-green-100)', + 200: 'var(--color-green-200)', + 300: 'var(--color-green-300)', + 400: 'var(--color-green-400)', + 500: 'var(--color-green-500)', + 600: 'var(--color-green-600)', + 700: 'var(--color-green-700)', + 800: 'var(--color-green-800)', + 900: 'var(--color-green-900)', + }, + yellow: { + 50: 'var(--color-yellow-50)', + 100: 'var(--color-yellow-100)', + 200: 'var(--color-yellow-200)', + 300: 'var(--color-yellow-300)', + 400: 'var(--color-yellow-400)', + 500: 'var(--color-yellow-500)', + 600: 'var(--color-yellow-600)', + 700: 'var(--color-yellow-700)', + 800: 'var(--color-yellow-800)', + 900: 'var(--color-yellow-900)', + }, + red: { + 50: 'var(--color-red-50)', + 100: 'var(--color-red-100)', + 200: 'var(--color-red-200)', + 300: 'var(--color-red-300)', + 400: 'var(--color-red-400)', + 500: 'var(--color-red-500)', + 600: 'var(--color-red-600)', + 700: 'var(--color-red-700)', + 800: 'var(--color-red-800)', + 900: 'var(--color-red-900)', + }, // Custom semantic colors for app-specific usage surface: 'rgb(var(--surface) / )', background: 'rgb(var(--background) / )',