diff --git a/src/components/MediaEditor/MediaEditor.tsx b/src/components/MediaEditor/MediaEditor.tsx index 1082d3e4..c954b86d 100644 --- a/src/components/MediaEditor/MediaEditor.tsx +++ b/src/components/MediaEditor/MediaEditor.tsx @@ -22,7 +22,7 @@ import type { PlaybackSegment, PlaybackSpeed, } from '../TranscriptView/transcript'; -import { PLAYBACK_SPEEDS } from '../TranscriptView/transcript'; +import { PLAYBACK_SPEEDS, isSilenceType } from '../TranscriptView/transcript'; import { useTranscriptEdits } from '../../hooks/useTranscriptEdits'; import { MediaPlayer, @@ -315,10 +315,25 @@ export const MediaEditor = React.forwardRef( // -- Refs -- const playerRef = React.useRef(null); - // Mirror the internal player handle to the host-provided ref, if any + // Mirror the internal player handle to the host-provided ref via a STABLE + // delegating proxy: every call forwards to playerRef.current at call time. + // (Snapshotting playerRef.current with [] deps captured the first-commit + // handle — ref assignment doesn't re-render, so hosts could hold a stale + // handle whose mediaElement stayed null forever.) React.useImperativeHandle( externalPlayerRef, - () => playerRef.current as MediaPlayerRef, + (): MediaPlayerRef => ({ + seekToMs: (timeMs) => playerRef.current?.seekToMs(timeMs), + play: () => playerRef.current?.play(), + pause: () => playerRef.current?.pause(), + getCurrentTimeMs: () => playerRef.current?.getCurrentTimeMs() ?? 0, + getDurationMs: () => playerRef.current?.getDurationMs() ?? 0, + isPaused: () => playerRef.current?.isPaused() ?? true, + setPlaybackRate: (rate) => playerRef.current?.setPlaybackRate(rate), + get mediaElement() { + return playerRef.current?.mediaElement ?? null; + }, + }), [] ); const contentRef = React.useRef(null); @@ -580,7 +595,7 @@ export const MediaEditor = React.forwardRef( .filter((ew): ew is EditableWord => ew !== undefined && !ew.deleted); if (selectedWords.length === 0) return; const selectedText = selectedWords - .filter((ew) => ew.word.wordType !== 'silence') + .filter((ew) => !isSilenceType(ew.word.wordType)) .map((ew) => ew.word.text) .join(' '); e.preventDefault(); diff --git a/src/components/MediaEditor/ScriptPanel.tsx b/src/components/MediaEditor/ScriptPanel.tsx index a7176591..9f176e5f 100644 --- a/src/components/MediaEditor/ScriptPanel.tsx +++ b/src/components/MediaEditor/ScriptPanel.tsx @@ -74,6 +74,16 @@ async function parseScript( `Entry ${i + 1}: each entry needs word.text, word.startMs and word.endMs.` ); } + if ( + w.wordType !== undefined && + w.wordType !== 'word' && + w.wordType !== 'silence' && + w.wordType !== 'silence-newline' + ) { + throw new Error( + `Entry ${i + 1}: wordType must be 'word', 'silence' or 'silence-newline'.` + ); + } const word: TranscriptWord = { text: w.text, startMs: w.startMs, diff --git a/src/components/MediaEditor/WordEditorModal.tsx b/src/components/MediaEditor/WordEditorModal.tsx index 77e50038..3eff62b3 100644 --- a/src/components/MediaEditor/WordEditorModal.tsx +++ b/src/components/MediaEditor/WordEditorModal.tsx @@ -17,6 +17,7 @@ import { } from '../Modal'; import { Button } from '../Button'; import { Input } from '../Input'; +import { isSilenceType } from '../TranscriptView/transcript'; import type { EditableWord } from '../TranscriptView/transcript'; export interface WordEditorModalProps { @@ -53,7 +54,7 @@ export const WordEditorModal: React.FC = ({ const [error, setError] = React.useState(null); const inputRef = React.useRef(null); - const isSilence = editableWord?.word.wordType === 'silence'; + const isSilence = isSilenceType(editableWord?.word.wordType); const durationMs = editableWord ? editableWord.word.endMs - editableWord.word.startMs : 0; diff --git a/src/components/MediaPlayer/MediaPlayer.tsx b/src/components/MediaPlayer/MediaPlayer.tsx index 824d5eb9..c27944f7 100644 --- a/src/components/MediaPlayer/MediaPlayer.tsx +++ b/src/components/MediaPlayer/MediaPlayer.tsx @@ -134,6 +134,13 @@ export const MediaPlayer = React.forwardRef( ref ) => { const [error, setError] = React.useState(null); + + // A failed load must not outlive its src: clearing on change lets a host + // swap in a corrected URL without being trapped on the error surface + // (which unmounts the media element, so the new src would never load). + React.useEffect(() => { + setError(null); + }, [src]); const resolvedKind = kind ?? inferMediaKind(src); const transport = useMediaTransport({ diff --git a/src/components/TranscriptView/TranscriptView.tsx b/src/components/TranscriptView/TranscriptView.tsx index 11e42814..d23e4cda 100644 --- a/src/components/TranscriptView/TranscriptView.tsx +++ b/src/components/TranscriptView/TranscriptView.tsx @@ -298,7 +298,7 @@ export const TranscriptView = React.forwardRef< className={`hover:bg-muted focus-visible:ring-ring flex w-full cursor-pointer gap-3 rounded px-2 py-1 text-left text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none ${ isActive ? 'bg-primary-500/20' : '' }`} - {...activationProps(() => seekTo(row.startMs))} + onClick={() => seekTo(row.startMs)} > {showTimestamps && ( diff --git a/src/components/TranscriptView/transcript.ts b/src/components/TranscriptView/transcript.ts index e1924f96..ee1680ba 100644 --- a/src/components/TranscriptView/transcript.ts +++ b/src/components/TranscriptView/transcript.ts @@ -11,6 +11,14 @@ /** Type of transcript word - 'word' for spoken content, 'silence' for detected gaps, 'silence-newline' for longer pauses */ export type WordType = 'word' | 'silence' | 'silence-newline'; +/** True for BOTH silence pseudo-word types ('silence' and 'silence-newline'). + * Use this instead of comparing against 'silence' directly — checking only one + * of the two types is the root cause of a whole family of review findings + * (stats, splitting, filler analysis, clipboard copy). */ +export function isSilenceType(wordType: WordType | undefined): boolean { + return wordType === 'silence' || wordType === 'silence-newline'; +} + export interface TranscriptWord { text: string; startMs: number; @@ -50,7 +58,10 @@ export interface Transcript { * Used in the edited timeline to track deletions and reordering. */ export interface EditableWord { - /** Reference to original word in transcript.words */ + /** Index into the SILENCE-INSERTED baseline timeline (transcript.words + * with detected silence pseudo-words interleaved) — NOT into + * transcript.words. -1 for split/inserted entries with no baseline slot. + * Consumers persisting edits should key by this timeline. */ originalIndex: number; /** The word data (from original transcript) */ word: TranscriptWord; diff --git a/src/hooks/useTranscriptEdits.test.ts b/src/hooks/useTranscriptEdits.test.ts index 24c5fd37..9626afc2 100644 --- a/src/hooks/useTranscriptEdits.test.ts +++ b/src/hooks/useTranscriptEdits.test.ts @@ -117,6 +117,19 @@ describe('getSpeedAtIndex', () => { expect(getSpeedAtIndex(2, markers, 1)).toBe(1.5); expect(getSpeedAtIndex(3, markers, 1)).toBe(2); }); + + it('is order-independent for external callers (#327 review)', () => { + // Public helper: callers may pass markers in arbitrary order. + const unsorted: SpeedMarker[] = [ + { wordIndex: 5, speed: 2 }, + { wordIndex: 1, speed: 1.5 }, + { wordIndex: 3, speed: 0.5 }, + ]; + expect(getSpeedAtIndex(0, unsorted, 1)).toBe(1); + expect(getSpeedAtIndex(1, unsorted, 1)).toBe(1.5); + expect(getSpeedAtIndex(4, unsorted, 1)).toBe(0.5); + expect(getSpeedAtIndex(9, unsorted, 1)).toBe(2); + }); }); // ============================================================================ @@ -233,3 +246,139 @@ describe('useTranscriptEdits', () => { expect((words as EditableWord[])[0].deleted).toBe(true); }); }); + +// ============================================================================ +// Regression tests for the ui#323 review findings +// ============================================================================ + +// A transcript with a leading pause and an inter-word gap, so the derived +// timeline interleaves silence pseudo-words: [sil, A, sil-nl, B] +const gappy: Transcript = { + durationMs: 4600, + words: [ + { text: 'Alpha', startMs: 600, endMs: 1000 }, + { text: 'Beta', startMs: 3000, endMs: 3500 }, + ], +}; + +describe('ui#323 / #327-review regressions', () => { + it('counts silence-newline entries as silences, not words (finding 3)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: gappy }) + ); + // Timeline: [silence 0-600][Alpha][silence-newline 1000-3000][Beta] + expect(result.current.stats.activeWordCount).toBe(2); + expect(result.current.stats.activeSilenceCount).toBe(2); + }); + + it('counts default filler words exactly once (findings 4/5)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: packed }) + ); + expect(result.current.fillerAnalysis.matchCounts.get('um')).toBe(1); + expect(result.current.fillerAnalysis.durations.get('um')).toBe(300); + }); + + it('can split a silence-newline entry (finding 6)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: gappy }) + ); + const nlIndex = result.current.editedWords.findIndex( + (ew) => ew.word.wordType === 'silence-newline' + ); + expect(nlIndex).toBeGreaterThan(-1); + act(() => result.current.splitSilence(nlIndex, [1, 1])); + // The 2s newline silence is replaced by two 1s segments + expect(result.current.editedWords.filter((ew) => ew.inserted)).toHaveLength( + 2 + ); + }); + + it('keeps deletions on the SAME words across a threshold rebuild (finding 2)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: gappy }) + ); + // Delete 'Beta' (the second non-silence word) + const betaIdx = result.current.editedWords.findIndex( + (ew) => ew.word.text === 'Beta' + ); + act(() => result.current.toggleWordDeleted(betaIdx)); + // Change thresholds so the silence layout changes entirely + act(() => result.current.setSilenceThresholds(100, 5000)); + const beta = result.current.editedWords.find( + (ew) => ew.word.text === 'Beta' + ); + const alpha = result.current.editedWords.find( + (ew) => ew.word.text === 'Alpha' + ); + expect(beta?.deleted).toBe(true); + expect(alpha?.deleted).toBe(false); + }); + + it('clears hasEdits after undoing back to baseline with silences present (finding 1)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: gappy }) + ); + act(() => result.current.toggleWordDeleted(1)); + expect(result.current.hasEdits).toBe(true); + act(() => result.current.undo()); + expect(result.current.hasEdits).toBe(false); + }); + + it('drops stale undo snapshots on a threshold rebuild (#327 review)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: gappy }) + ); + const betaIdx = result.current.editedWords.findIndex( + (ew) => ew.word.text === 'Beta' + ); + act(() => result.current.toggleWordDeleted(betaIdx)); + expect(result.current.undoStack).toHaveLength(1); + // The snapshot in the stack was captured against the old silence layout + act(() => result.current.setSilenceThresholds(100, 5000)); + expect(result.current.undoStack).toHaveLength(0); + // undo() is a no-op instead of restoring a stale-layout timeline + const before = result.current.editedWords; + act(() => result.current.undo()); + expect(result.current.editedWords).toBe(before); + }); + + it('keeps deletions on the right words across a threshold rebuild with pasted words (#327 review)', () => { + const { result } = renderHook(() => + useTranscriptEdits({ transcript: packed }) + ); + // Copy 'Hello' and paste the copy before 'world'. The pasted word keeps + // its source originalIndex, but the rebuild re-inits from the original + // transcript (dropping inserts) — so it must not occupy a slot in the + // deletion keyspace, or every word after it shifts. + act(() => result.current.copy([0])); + act(() => result.current.paste(2, true)); + const worldIdx = result.current.editedWords.findIndex( + (ew) => ew.word.text === 'world' + ); + act(() => result.current.toggleWordDeleted(worldIdx)); + + act(() => result.current.setSilenceThresholds(100, 5000)); + + // The rebuild drops the pasted copy; 'world' must still be the deleted one + expect(result.current.editedWords).toHaveLength(3); + const [hello, um, world] = result.current.editedWords; + expect(world.deleted).toBe(true); + expect(hello.deleted).toBe(false); + expect(um.deleted).toBe(false); + }); + + it('initializes hasEdits=true for saved text-only edits (finding 13)', () => { + const baseline = initEditableWords(packed); + const withTextEdit: EditableWord[] = baseline.map((ew, i) => + i === 0 ? { ...ew, word: { ...ew.word, text: 'Howdy' } } : ew + ); + const { result } = renderHook(() => + useTranscriptEdits({ + transcript: packed, + initialEditedWords: withTextEdit, + }) + ); + expect(result.current.hasEdits).toBe(true); + }); +}); diff --git a/src/hooks/useTranscriptEdits.ts b/src/hooks/useTranscriptEdits.ts index 78c5d621..648ab0ef 100644 --- a/src/hooks/useTranscriptEdits.ts +++ b/src/hooks/useTranscriptEdits.ts @@ -20,6 +20,7 @@ import type { PlaybackSpeed, SpeedMarker, } from '../components/TranscriptView/transcript'; +import { isSilenceType } from '../components/TranscriptView/transcript'; /** Default filler words offered for removal */ export const DEFAULT_FILLER_WORDS = [ @@ -137,18 +138,16 @@ export function getSpeedAtIndex( speedMarkers: SpeedMarker[], defaultSpeed: PlaybackSpeed ): PlaybackSpeed { - // Sort markers by word index (should already be sorted, but be safe) - const sortedMarkers = [...speedMarkers].sort( - (a, b) => a.wordIndex - b.wordIndex - ); - - // Find the last marker at or before this word index + // Single O(n) pass that takes the marker with the greatest wordIndex at or + // before the target. This avoids the per-call copy+sort (stats calls this + // once per word) without assuming callers pass sorted markers — it is a + // public helper, so arbitrary order must still yield the right answer. let effectiveSpeed = defaultSpeed; - for (const marker of sortedMarkers) { - if (marker.wordIndex <= wordIndex) { + let nearest = -1; + for (const marker of speedMarkers) { + if (marker.wordIndex <= wordIndex && marker.wordIndex > nearest) { + nearest = marker.wordIndex; effectiveSpeed = marker.speed; - } else { - break; } } return effectiveSpeed; @@ -320,7 +319,8 @@ export interface UseTranscriptEditsResult { ) => void; // -- Silence thresholds -- - /** Rebuild silence detection with new thresholds, preserving word deletions */ + /** Rebuild silence detection with new thresholds, preserving word deletions. + * Not undoable — clears the undo stack (prior snapshots reference the old silence layout). */ setSilenceThresholds: (minMs: number, nlMs: number) => void; /** Current silence detection thresholds (ms) */ silenceThresholds: { minSilenceMs: number; nlSilenceMs: number }; @@ -383,9 +383,25 @@ export function useTranscriptEdits( ); const [clipboard, setClipboard] = useState(null); const [hasEdits, setHasEdits] = useState(() => { - // Check if initial state has edits + // Compare saved state against the derived baseline: deletions, insertions, + // length changes, AND text edits (text-only edits previously initialized + // hasEdits=false, which also suppressed onChange persistence). if (initialEditedWords) { - return initialEditedWords.some((ew) => ew.deleted || ew.inserted); + const baseline = initEditableWords( + transcript, + initialMinSilenceMs, + initialNlSilenceMs + ); + return ( + initialEditedWords.length !== baseline.length || + initialEditedWords.some( + (ew, i) => + ew.deleted || + ew.inserted || + ew.originalIndex !== i || + ew.word.text !== baseline[i].word.text + ) + ); } return false; }); @@ -438,11 +454,21 @@ export function useTranscriptEdits( const previousState = undoStack[undoStack.length - 1]; setUndoStack((prev) => prev.slice(0, -1)); setEditedWords(previousState); + // Compare against the silence-inserted baseline (what the editor actually + // starts from), not raw transcript.words — with any detected silence the + // lengths never matched and hasEdits stayed true after undoing everything. + const baseline = initEditableWords(transcript, minSilenceMs, nlSilenceMs); const isOriginal = - previousState.every((ew, i) => ew.originalIndex === i && !ew.deleted) && - previousState.length === transcript.words.length; + previousState.length === baseline.length && + previousState.every( + (ew, i) => + ew.originalIndex === i && + !ew.deleted && + !ew.inserted && + ew.word.text === baseline[i].word.text + ); setHasEdits(!isOriginal); - }, [undoStack, transcript.words.length]); + }, [undoStack, transcript, minSilenceMs, nlSilenceMs]); // Toggle deleted state on a single word const toggleWordDeleted = useCallback( @@ -565,7 +591,7 @@ export function useTranscriptEdits( const splitSilence = useCallback( (index: number, durationsSec: number[]) => { const ew = editedWords[index]; - if (!ew || ew.word.wordType !== 'silence') return; + if (!ew || !isSilenceType(ew.word.wordType)) return; pushUndo(); @@ -632,11 +658,7 @@ export function useTranscriptEdits( if (ew.deleted) return ew; // Check if it's a silence that should be removed - if ( - (ew.word.wordType === 'silence' || - ew.word.wordType === 'silence-newline') && - silenceThresholdMs !== null - ) { + if (isSilenceType(ew.word.wordType) && silenceThresholdMs !== null) { const durationMs = ew.word.endMs - ew.word.startMs; if (durationMs > silenceThresholdMs) { return { ...ew, deleted: true }; @@ -645,10 +667,7 @@ export function useTranscriptEdits( } // Check if it's a filler word that should be removed - if ( - ew.word.wordType !== 'silence' && - ew.word.wordType !== 'silence-newline' - ) { + if (!isSilenceType(ew.word.wordType)) { const wordText = normalizeWordText(ew.word.text); if (fillerSet.has(wordText)) { return { ...ew, deleted: true }; @@ -676,17 +695,24 @@ export function useTranscriptEdits( (newMinSilenceMs: number, newNlSilenceMs: number) => { setMinSilenceMs(newMinSilenceMs); setNlSilenceMs(newNlSilenceMs); - // Rebuild the editable words with new silence detection - // Preserve deleted status for actual words (not silences) + // Rebuild the editable words with new silence detection. + // Preserve deleted status for actual words (not silences), keyed by the + // word's ORDINAL among non-silence words — the same key the restore loop + // below uses. (Keying by originalIndex — a position in the old + // silence-inserted array — mismatched the restore counter and applied + // deletions to the wrong words after a threshold change.) + // Pasted words keep their source originalIndex but are dropped by the + // rebuild, so they must not occupy a slot in this keyspace either. const wordDeletedStatus = new Map(); + let saveIdx = 0; editedWords.forEach((ew) => { if ( - ew.word.wordType !== 'silence' && - ew.word.wordType !== 'silence-newline' && - ew.originalIndex >= 0 + !isSilenceType(ew.word.wordType) && + ew.originalIndex >= 0 && + !ew.inserted ) { - // Map original word index to deleted status - wordDeletedStatus.set(ew.originalIndex, ew.deleted); + wordDeletedStatus.set(saveIdx, ew.deleted); + saveIdx++; } }); @@ -697,13 +723,10 @@ export function useTranscriptEdits( newNlSilenceMs ); - // Restore deleted status for words + // Restore deleted status for words (same ordinal keyspace as the save) let wordIdx = 0; const restoredWords = newEditedWords.map((ew) => { - if ( - ew.word.wordType !== 'silence' && - ew.word.wordType !== 'silence-newline' - ) { + if (!isSilenceType(ew.word.wordType)) { const wasDeleted = wordDeletedStatus.get(wordIdx) ?? false; wordIdx++; return { ...ew, deleted: wasDeleted }; @@ -712,6 +735,11 @@ export function useTranscriptEdits( }); setEditedWords(restoredWords); + // Undo snapshots were captured against the previous silence layout; + // restoring one after a rebuild would resurrect silence chips that no + // longer match the current thresholds. Threshold changes are not + // undoable, so drop the stale history rather than restore it. + setUndoStack([]); }, [editedWords, transcript] ); @@ -779,16 +807,16 @@ export function useTranscriptEdits( const stats = useMemo(() => { // Count active (non-deleted) words (excluding silences for word count) const activeWordCount = editedWords.filter( - (ew) => !ew.deleted && ew.word.wordType !== 'silence' + (ew) => !ew.deleted && !isSilenceType(ew.word.wordType) ).length; const activeSilenceCount = editedWords.filter( - (ew) => !ew.deleted && ew.word.wordType === 'silence' + (ew) => !ew.deleted && isSilenceType(ew.word.wordType) ).length; const deletedWordCount = editedWords.filter( - (ew) => ew.deleted && ew.word.wordType !== 'silence' + (ew) => ew.deleted && !isSilenceType(ew.word.wordType) ).length; const deletedSilenceCount = editedWords.filter( - (ew) => ew.deleted && ew.word.wordType === 'silence' + (ew) => ew.deleted && isSilenceType(ew.word.wordType) ).length; // Calculate edited duration (sum of non-deleted words/silences, adjusted for speed markers) @@ -813,34 +841,21 @@ export function useTranscriptEdits( // Occurrence counts per filler (and every active word, for custom fillers) const matchCounts = new Map(); for (const ew of editedWords) { - if (ew.deleted || ew.word.wordType === 'silence') continue; + if (ew.deleted || isSilenceType(ew.word.wordType)) continue; const wordText = normalizeWordText(ew.word.text); - for (const filler of DEFAULT_FILLER_WORDS) { - if (wordText === filler) { - matchCounts.set(filler, (matchCounts.get(filler) || 0) + 1); - } - } - // Also check for the exact word in case it's a custom filler + // One increment per occurrence — DEFAULT_FILLER_WORDS entries were + // previously counted twice (once in a defaults loop, once here). matchCounts.set(wordText, (matchCounts.get(wordText) || 0) + 1); } // Total duration per filler word type const durations = new Map(); for (const ew of editedWords) { - if ( - ew.deleted || - ew.word.wordType === 'silence' || - ew.word.wordType === 'silence-newline' - ) - continue; + if (ew.deleted || isSilenceType(ew.word.wordType)) continue; const wordText = normalizeWordText(ew.word.text); const wordDuration = ew.word.endMs - ew.word.startMs; - for (const filler of DEFAULT_FILLER_WORDS) { - if (wordText === filler) { - durations.set(filler, (durations.get(filler) || 0) + wordDuration); - } - } - // Also track duration for the exact word in case it's a custom filler + // One accumulation per occurrence — DEFAULT_FILLER_WORDS entries were + // previously accumulated twice, inflating time-saved estimates 2x. durations.set(wordText, (durations.get(wordText) || 0) + wordDuration); } @@ -851,11 +866,7 @@ export function useTranscriptEdits( let durationMs = 0; for (const ew of editedWords) { if (ew.deleted) continue; - if ( - ew.word.wordType !== 'silence' && - ew.word.wordType !== 'silence-newline' - ) - continue; + if (!isSilenceType(ew.word.wordType)) continue; const silenceDuration = ew.word.endMs - ew.word.startMs; if (silenceDuration > thresholdMs) { count++; diff --git a/src/tailwind-preset.ts b/src/tailwind-preset.ts index e494e4bb..122e1d2c 100644 --- a/src/tailwind-preset.ts +++ b/src/tailwind-preset.ts @@ -39,6 +39,30 @@ * @deprecated For Tailwind CSS 4 users — use the `@source` directive instead. */ export const miewebUISafelist = [ + // Media stack (MediaEditor / TranscriptView / MediaPlayer) — opacity-modifier + // and state variants introduced with the media components (ui#323 finding): + 'bg-muted/20', + 'bg-primary-50', + 'bg-primary-500/20', + 'bg-primary-500/40', + 'bg-success/15', + 'bg-warning/20', + 'bg-warning/40', + 'border-l-warning', + 'border-primary-600', + 'border-warning/50', + 'dark:bg-primary-950', + 'hover:bg-destructive/20', + 'hover:bg-muted', + 'hover:bg-muted/70', + 'hover:bg-primary-500/15', + 'hover:bg-primary-500/30', + 'hover:bg-success/25', + 'hover:text-destructive', + 'outline-destructive', + 'outline-primary-500', + 'outline-warning', + 'text-primary-500', // Semantic colors 'border-border', 'border-input',