From 04864c57279dd6c684288587c85816faeb8ac54e Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 21:26:19 +0200 Subject: [PATCH 1/9] feat: enhance UI components with dark mode support and language preferences - Updated ExampleContentMenu, TextInputPanel, and various preference components to support dark mode styling. - Added target language selection functionality in WorkspacePage, allowing users to set and persist language preferences. - Enhanced FormatTransformService to handle target language in transformations. - Improved accessibility and visual consistency across components with updated styles for dark mode. - Refactored history storage to include target language metadata for processing history entries. --- src/components/input/ExampleContentMenu.tsx | 26 +++--- src/components/input/TextInputPanel.tsx | 20 +++- .../preferences/FormatPresetSelector.tsx | 20 ++-- .../preferences/FormatPreviewCard.tsx | 24 ++--- src/components/preferences/FormatSelector.tsx | 22 +++-- .../services/FormatTransformService.ts | 51 +++++++++- src/lib/storage/history.ts | 1 + src/pages/preferences/PreferencesPage.tsx | 18 ++-- src/pages/workspace/WorkspacePage.tsx | 93 +++++++++++++++++-- 9 files changed, 204 insertions(+), 71 deletions(-) diff --git a/src/components/input/ExampleContentMenu.tsx b/src/components/input/ExampleContentMenu.tsx index 9ca95b4..9b21c50 100644 --- a/src/components/input/ExampleContentMenu.tsx +++ b/src/components/input/ExampleContentMenu.tsx @@ -21,10 +21,10 @@ export const ExampleContentMenu = ({ const getCategoryColor = (category: ExampleContent['category']) => { const colors = { - technical: 'bg-blue-100 text-blue-700', - academic: 'bg-purple-100 text-purple-700', - news: 'bg-green-100 text-green-700', - legal: 'bg-amber-100 text-amber-700', + technical: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', + academic: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-200', + news: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-200', + legal: 'bg-amber-100 text-amber-700 dark:bg-amber-900/60 dark:text-amber-200', } return colors[category] } @@ -143,13 +143,13 @@ export const ExampleContentMenu = ({ type="button" onClick={() => setIsOpen((prev) => !prev)} disabled={disabled} - className="flex items-center gap-2 rounded-xl border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-neutral-300 disabled:hover:bg-white disabled:hover:text-neutral-700" + className="flex items-center gap-2 rounded-xl border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50 hover:text-primary-700 focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-neutral-300 disabled:hover:bg-white disabled:hover:text-neutral-700 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:border-primary-400 dark:hover:bg-primary-400/10 dark:hover:text-primary-200" aria-haspopup="menu" aria-expanded={isOpen} aria-controls={isOpen ? menuId : undefined} > - Load example + Load example -
-

+

+

Example content

@@ -199,7 +199,7 @@ export const ExampleContentMenu = ({ tabIndex={focusedIndex === index ? 0 : -1} onClick={() => handleSelectExample(example)} onFocus={() => setFocusedIndex(index)} - className="w-full px-4 py-3 text-left transition hover:bg-neutral-50 focus:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500" + className="w-full px-4 py-3 text-left transition hover:bg-neutral-50 focus:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:hover:bg-neutral-800/70 dark:focus:bg-neutral-800/70" role="menuitem" >
@@ -210,8 +210,8 @@ export const ExampleContentMenu = ({
-

{example.title}

-

+

{example.title}

+

{example.description}

diff --git a/src/components/input/TextInputPanel.tsx b/src/components/input/TextInputPanel.tsx index 58ed73b..93ae877 100644 --- a/src/components/input/TextInputPanel.tsx +++ b/src/components/input/TextInputPanel.tsx @@ -25,6 +25,7 @@ interface TextInputPanelProps { onProcessingError?: (error: Error) => void initialText?: string selectedFormats?: FormatType[] + targetLanguage?: string onInputStateChange?: (hasText: boolean) => void onProcessingStart?: () => void onOperationProgress?: (progress: OperationProgress | null) => void @@ -41,6 +42,7 @@ export const TextInputPanel = ({ onProcessingError, initialText = '', selectedFormats: propSelectedFormats, + targetLanguage, onInputStateChange, onProcessingStart, onOperationProgress, @@ -97,9 +99,12 @@ export const TextInputPanel = ({ onInputStateChange?.(text.trim().length > 0) }, [text, onInputStateChange]) + const preferenceSnapshot = loadPreferences() + // Format transform hook - auto-initialize with reading level const formatTransform = useFormatTransform(true, { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferenceSnapshot.readingLevel || undefined, + targetLanguage: targetLanguage || preferenceSnapshot.targetLanguage || undefined, }) // Pass progress updates to parent @@ -203,13 +208,18 @@ export const TextInputPanel = ({ // Store the original text before processing const originalTextSnapshot = text + const preferences = loadPreferences() + const readingLevelPreference = preferences.readingLevel || undefined + const effectiveTargetLanguage = + targetLanguage || preferences.targetLanguage || undefined try { // Check if format transform service is ready if (!formatTransform.isReady) { setStatus('initializing') await formatTransform.initialize({ - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: readingLevelPreference, + targetLanguage: effectiveTargetLanguage, }) } @@ -223,7 +233,8 @@ export const TextInputPanel = ({ text, selectedFormats[0], { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: readingLevelPreference, + targetLanguage: effectiveTargetLanguage, } ) @@ -249,7 +260,8 @@ export const TextInputPanel = ({ onStreamingUpdate?.(format, content, isComplete) }, { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: readingLevelPreference, + targetLanguage: effectiveTargetLanguage, } ) diff --git a/src/components/preferences/FormatPresetSelector.tsx b/src/components/preferences/FormatPresetSelector.tsx index 5557e18..d82ce57 100644 --- a/src/components/preferences/FormatPresetSelector.tsx +++ b/src/components/preferences/FormatPresetSelector.tsx @@ -52,15 +52,15 @@ export const FormatPresetSelector = ({
-

Quick Presets

-

Common format combinations

+

Quick Presets

+

Common format combinations

{selectedPresetId && ( @@ -78,8 +78,8 @@ export const FormatPresetSelector = ({ disabled={disabled} className={`rounded-xl border px-4 py-3 text-left transition hc-surface ${ isSelected - ? 'border-synapse-500 bg-synapse-50 shadow-soft hc-surface--active' - : 'border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50' + ? 'border-synapse-500 bg-synapse-50 shadow-soft hc-surface--active dark:border-synapse-400 dark:bg-synapse-900/30' + : 'border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:border-neutral-500 dark:hover:bg-neutral-800/60' } ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`} aria-pressed={isSelected} > @@ -88,7 +88,9 @@ export const FormatPresetSelector = ({
-

{preset.label}

+

{preset.label}

{isSelected && ( - + Active )}
-

{preset.description}

+

{preset.description}

diff --git a/src/components/preferences/FormatPreviewCard.tsx b/src/components/preferences/FormatPreviewCard.tsx index 7abe4b5..0f13fd6 100644 --- a/src/components/preferences/FormatPreviewCard.tsx +++ b/src/components/preferences/FormatPreviewCard.tsx @@ -64,8 +64,8 @@ export const FormatPreviewCard = ({ disabled={disabled} className={`flex h-full flex-col items-start rounded-2xl border px-5 py-4 text-left transition hc-surface ${ isSelected - ? 'border-primary-600 bg-primary-50 shadow-[0_0_0_4px_rgba(59,130,246,0.15)] hc-surface--active' - : 'border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50' + ? 'border-primary-600 bg-primary-50 shadow-[0_0_0_4px_rgba(59,130,246,0.15)] hc-surface--active dark:border-primary-400 dark:bg-primary-900/30 dark:shadow-[0_0_0_4px_rgba(56,189,248,0.25)]' + : 'border-neutral-200 hover:border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:border-neutral-500 dark:hover:bg-neutral-800/60' } ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`} aria-pressed={isSelected} > @@ -76,13 +76,15 @@ export const FormatPreviewCard = ({
-

{config.label}

+

{config.label}

{isSelected && ( - + Selected )} @@ -119,14 +121,14 @@ export const FormatPreviewCard = ({
{/* Description */} -

{config.description}

+

{config.description}

{/* Preview */} -
-

+

+

Preview

-
+        
           {config.preview}
         
diff --git a/src/components/preferences/FormatSelector.tsx b/src/components/preferences/FormatSelector.tsx index 8612808..eaa2524 100644 --- a/src/components/preferences/FormatSelector.tsx +++ b/src/components/preferences/FormatSelector.tsx @@ -76,10 +76,12 @@ export const FormatSelector = ({ {/* Divider */}
-
+
- or choose individual formats + + or choose individual formats +
@@ -97,10 +99,10 @@ export const FormatSelector = ({ {/* Selected formats summary */} {selectedFormats.length > 0 && isValidFormatCombination(selectedFormats) && ( -
+
-

+

{selectedFormats.length} format{selectedFormats.length > 1 ? 's' : ''} selected

-

+

Your content will be restructured into: {getFormatLabels(selectedFormats)}

@@ -127,10 +129,10 @@ export const FormatSelector = ({ {/* Warning for invalid combinations */} {!isValidFormatCombination(selectedFormats) && ( -
+
-

Invalid format combination

-

+

Invalid format combination

+

Please select at least one format.

diff --git a/src/lib/chrome-ai/services/FormatTransformService.ts b/src/lib/chrome-ai/services/FormatTransformService.ts index ce2c559..4de5930 100644 --- a/src/lib/chrome-ai/services/FormatTransformService.ts +++ b/src/lib/chrome-ai/services/FormatTransformService.ts @@ -1,6 +1,7 @@ import type { FormatType } from '../types' import { PromptService, type PromptServiceConfig } from './PromptService' import { getFormatConfig, isValidFormatCombination } from '@/constants/formats' +import { findLanguageByCode } from '@/constants/languages' import { validateTextInput, sanitizeText, @@ -16,6 +17,11 @@ export interface FormatTransformConfig extends Omit { const chunks = chunkText(content) @@ -180,7 +211,7 @@ export class FormatTransformService { ) } - const prompt = this.buildFormatPrompt(chunk.text, format, readingLevel, { + const prompt = this.buildFormatPrompt(chunk.text, format, readingLevel, targetLanguage, { index: chunk.index, total: chunks.length, previousContext: chunk.context, @@ -203,6 +234,7 @@ export class FormatTransformService { content: string, format: FormatType, readingLevel: string | undefined, + targetLanguage: string | undefined, options?: TransformOptions ): AsyncIterable { const chunks = chunkText(content) @@ -228,7 +260,7 @@ export class FormatTransformService { ) } - const prompt = this.buildFormatPrompt(chunk.text, format, readingLevel, { + const prompt = this.buildFormatPrompt(chunk.text, format, readingLevel, targetLanguage, { index: chunk.index, total: chunks.length, previousContext: chunk.context, @@ -358,6 +390,7 @@ export class FormatTransformService { content: string, format: FormatType, readingLevel?: string, + targetLanguage?: string, chunkContext?: ChunkPromptContext ): string { const formatConfig = getFormatConfig(format) @@ -371,6 +404,14 @@ export class FormatTransformService { guidanceSegments.push(`IMPORTANT: Adapt the language to a ${readingLevel} reading level.`) } + if (targetLanguage) { + const languageDetails = findLanguageByCode(targetLanguage) + const languageLabel = languageDetails?.name ?? targetLanguage + guidanceSegments.push( + `IMPORTANT: Produce the output in ${languageLabel} (language code: ${targetLanguage}).` + ) + } + if (chunkContext) { const chunkGuidance: string[] = [ `You are processing part ${chunkContext.index + 1} of ${chunkContext.total} of a larger document. Maintain continuity with prior sections, avoid repeating earlier information, and keep transitions smooth.`, diff --git a/src/lib/storage/history.ts b/src/lib/storage/history.ts index 7d588f9..e8e1397 100644 --- a/src/lib/storage/history.ts +++ b/src/lib/storage/history.ts @@ -16,6 +16,7 @@ export interface ProcessingHistoryEntry { favorite: boolean metadata?: { readingLevel?: string + targetLanguage?: string notes?: string } } diff --git a/src/pages/preferences/PreferencesPage.tsx b/src/pages/preferences/PreferencesPage.tsx index cd943a1..735b21b 100644 --- a/src/pages/preferences/PreferencesPage.tsx +++ b/src/pages/preferences/PreferencesPage.tsx @@ -175,10 +175,10 @@ export const PreferencesPage = () => { -
+
{ />
-

Theme preference

-

+

Theme preference

+

Currently using {theme} mode. Your preference is saved automatically.

@@ -301,11 +301,11 @@ export const PreferencesPage = () => { {hasPendingChanges && ( -
+
{ />
-

Unsaved changes

-

+

Unsaved changes

+

You have unsaved changes to your preferences. Click Save to apply them.

@@ -328,7 +328,7 @@ export const PreferencesPage = () => { diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index f93230e..078b6a8 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -16,6 +16,8 @@ import { useProcessingHistory } from '@/hooks/useProcessingHistory' import { useToast } from '@/hooks/useToast' import { loadPreferences, persistPreferences, PreferenceKey } from '@/lib/storage/preferences' import { FORMAT_OPTIONS, getFormatLabels } from '@/constants/formats' +import { LanguageSelector, type LanguageSelectorValue } from '@/components/input/LanguageSelector' +import { LANGUAGE_OPTIONS, findLanguageByCode } from '@/constants/languages' import type { FormatType } from '@/lib/chrome-ai/types' import type { ProcessingHistoryEntry, ProcessingHistorySource } from '@/lib/storage/history' import type { @@ -40,6 +42,8 @@ export const WorkspacePage = () => { const [completedFormats, setCompletedFormats] = useState>(new Set()) const [isStreaming, setIsStreaming] = useState(false) const [selectedFormats, setSelectedFormats] = useState(['paragraphs']) + const [targetLanguage, setTargetLanguage] = useState('en') + const [favoriteLanguages, setFavoriteLanguages] = useState([]) const [processingError, setProcessingError] = useState(null) const [extractedText, setExtractedText] = useState('') const [textInputKey, setTextInputKey] = useState(0) // Key to force re-render @@ -102,8 +106,6 @@ export const WorkspacePage = () => { const { showSuccess, showError } = useToast() - // Loaded but not yet used - will be used when language selector is added to workspace - const [, setTargetLanguage] = useState('en') const streamingResultsRef = useRef>>({}) const completedFormatsRef = useRef>(new Set()) @@ -112,6 +114,10 @@ export const WorkspacePage = () => { const prefs = loadPreferences() setSelectedFormats(prefs.formatSelection || ['paragraphs']) setTargetLanguage(prefs.targetLanguage || 'en') + setFavoriteLanguages(prefs.favoriteLanguages || []) + if (prefs.showComparison) { + setViewMode('comparison') + } }, []) // File upload hook @@ -239,13 +245,16 @@ export const WorkspacePage = () => { [formats[0]]: result, } + const preferences = loadPreferences() + addHistoryEntry({ source: lastProcessingSource, inputText: original, formats, results: resultsRecord, metadata: { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }, }) @@ -293,13 +302,16 @@ export const WorkspacePage = () => { return } + const preferences = loadPreferences() + addHistoryEntry({ source: lastProcessingSource, inputText: original, formats, results: mergedResults, metadata: { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }, }) @@ -391,6 +403,27 @@ export const WorkspacePage = () => { }) } + const handleTargetLanguageChange = (language: LanguageSelectorValue) => { + if (language === 'auto') { + return + } + + setTargetLanguage(language) + persistPreferences({ + [PreferenceKey.TargetLanguage]: language, + }) + } + + const handleToggleFavoriteLanguage = (code: string) => { + setFavoriteLanguages((prev) => { + const next = prev.includes(code) ? prev.filter((item) => item !== code) : [...prev, code] + persistPreferences({ + [PreferenceKey.FavoriteLanguages]: next, + }) + return next + }) + } + const handleHistoryRestore = (entry: ProcessingHistoryEntry) => { const restoredFormats = entry.formats.length > 0 ? entry.formats : selectedFormats if (restoredFormats.length > 0) { @@ -400,6 +433,13 @@ export const WorkspacePage = () => { }) } + if (entry.metadata?.targetLanguage && entry.metadata.targetLanguage !== targetLanguage) { + setTargetLanguage(entry.metadata.targetLanguage) + persistPreferences({ + [PreferenceKey.TargetLanguage]: entry.metadata.targetLanguage, + }) + } + setExtractedText(entry.input.text) setTextInputKey((prev) => prev + 1) setInputMode('text') @@ -484,9 +524,11 @@ export const WorkspacePage = () => { handleProcessingStart() try { + const preferences = loadPreferences() const { FormatTransformService } = await import('@/lib/chrome-ai/services/FormatTransformService') const service = new FormatTransformService({ - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }) // Initialize service @@ -495,7 +537,8 @@ export const WorkspacePage = () => { if (selectedFormats.length === 1) { // Single format processing const result = await service.transformToFormat(textContent, selectedFormats[0], { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }) handleProcessingComplete(result, textContent) @@ -507,7 +550,8 @@ export const WorkspacePage = () => { textContent, selectedFormats, { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, } )) { if (update.isComplete) { @@ -574,9 +618,11 @@ export const WorkspacePage = () => { handleProcessingStart() try { + const preferences = loadPreferences() const { FormatTransformService } = await import('@/lib/chrome-ai/services/FormatTransformService') const service = new FormatTransformService({ - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }) // Initialize service @@ -585,7 +631,8 @@ export const WorkspacePage = () => { if (selectedFormats.length === 1) { // Single format processing const result = await service.transformToFormat(textContent, selectedFormats[0], { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, }) handleProcessingComplete(result, textContent) @@ -597,7 +644,8 @@ export const WorkspacePage = () => { textContent, selectedFormats, { - readingLevel: loadPreferences().readingLevel || undefined, + readingLevel: preferences.readingLevel || undefined, + targetLanguage, } )) { if (update.isComplete) { @@ -1048,6 +1096,30 @@ export const WorkspacePage = () => {
+
+
+
+
+

Output language

+

+ Applies to future transformations and updates your saved preference. +

+
+ + {findLanguageByCode(targetLanguage)?.code ?? targetLanguage} + +
+ +
+
+ {inputMode === 'text' ? ( { onProcessingError={handleProcessingError} initialText={extractedText} selectedFormats={selectedFormats} + targetLanguage={targetLanguage} onInputStateChange={setHasTextInput} onProcessingStart={handleProcessingStart} onOperationProgress={handleOperationProgress} From 5b6df54870e4bcd28b379251962b6ca97beef2de Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 21:39:21 +0200 Subject: [PATCH 2/9] feat: integrate preferences storage subscription for language settings in WorkspacePage - Added subscription to preferences storage for target and favorite languages, allowing real-time updates to language settings. - Enhanced state management for language preferences, improving user experience and responsiveness in the WorkspacePage component. --- src/pages/workspace/WorkspacePage.tsx | 32 ++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspacePage.tsx b/src/pages/workspace/WorkspacePage.tsx index 078b6a8..310ead8 100644 --- a/src/pages/workspace/WorkspacePage.tsx +++ b/src/pages/workspace/WorkspacePage.tsx @@ -14,7 +14,12 @@ import { useFileUpload } from '@/hooks/useFileUpload' import { useContentExtraction } from '@/hooks/useContentExtraction' import { useProcessingHistory } from '@/hooks/useProcessingHistory' import { useToast } from '@/hooks/useToast' -import { loadPreferences, persistPreferences, PreferenceKey } from '@/lib/storage/preferences' +import { + loadPreferences, + persistPreferences, + PreferenceKey, + preferencesStorage, +} from '@/lib/storage/preferences' import { FORMAT_OPTIONS, getFormatLabels } from '@/constants/formats' import { LanguageSelector, type LanguageSelectorValue } from '@/components/input/LanguageSelector' import { LANGUAGE_OPTIONS, findLanguageByCode } from '@/constants/languages' @@ -120,6 +125,31 @@ export const WorkspacePage = () => { } }, []) + useEffect(() => { + const unsubscribeTarget = preferencesStorage.subscribe( + PreferenceKey.TargetLanguage, + ({ value }) => { + if (typeof value === 'string') { + setTargetLanguage(value) + } + } + ) + + const unsubscribeFavorites = preferencesStorage.subscribe( + PreferenceKey.FavoriteLanguages, + ({ value }) => { + if (Array.isArray(value)) { + setFavoriteLanguages(value) + } + } + ) + + return () => { + unsubscribeTarget?.() + unsubscribeFavorites?.() + } + }, []) + // File upload hook const fileUpload = useFileUpload({ onExtractionComplete: () => { From dae3671afa7cd2acec987d83973e9a72363801a1 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 21:42:59 +0200 Subject: [PATCH 3/9] refactor: simplify format selection logic in FormatSelector component - Streamlined the handleFormatToggle function to improve clarity and maintainability. - Consolidated format selection and deselection logic, ensuring at least one format is always selected. - Removed redundant checks and comments, enhancing code readability. --- src/components/preferences/FormatSelector.tsx | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/src/components/preferences/FormatSelector.tsx b/src/components/preferences/FormatSelector.tsx index eaa2524..130ff0c 100644 --- a/src/components/preferences/FormatSelector.tsx +++ b/src/components/preferences/FormatSelector.tsx @@ -25,32 +25,23 @@ export const FormatSelector = ({ const handleFormatToggle = (type: FormatType) => { const isSelected = selectedFormats.includes(type) - // If toggling summary - if (type === 'summary') { - if (isSelected) { - // Deselecting summary - onChangeFormats(selectedFormats.filter((f) => f !== type)) - } else { - // Selecting summary - replace all other formats - onChangeFormats(['summary']) + let updatedFormats: FormatType[] + + if (isSelected) { + const next = selectedFormats.filter((format) => format !== type) + + // Prevent empty selection – keep at least one format + if (next.length === 0) { + return } + + updatedFormats = next } else { - // Toggling a non-summary format - if (isSelected) { - // Deselecting this format - prevent empty selection - const newFormats = selectedFormats.filter((f) => f !== type) - if (newFormats.length > 0) { - onChangeFormats(newFormats) - } - // If this would result in empty selection, do nothing (keep at least one format) - } else { - // Selecting this format - remove summary if present - const newFormats = selectedFormats.filter((f) => f !== 'summary') - onChangeFormats([...newFormats, type]) - } + updatedFormats = [...selectedFormats, type] } - // Clear preset when manual selection changes + onChangeFormats(updatedFormats) + if (selectedPreset) { onChangePreset(null) } From 904d1e45122d13270cdedeeb85d2d6d72c776fb1 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 22:05:51 +0200 Subject: [PATCH 4/9] feat: enhance image extraction logic and add relative image source resolution - Introduced a new function to resolve relative image sources against the final URL, improving image extraction accuracy. - Updated the extractImages function to utilize the new resolution logic, ensuring proper handling of image sources. - Added a test case to verify the functionality of resolving relative image sources in the readability parser. - Refactored the PreviewTooltip component to improve accessibility and user interaction with enhanced event handling. --- .../workspace/FormatQuickSelector.tsx | 35 ++++++++++++---- .../readability-parser.test.ts | 20 +++++++++ .../content-extraction/readability-parser.ts | 42 +++++++++++++++++-- src/pages/preferences/PreferencesPage.tsx | 18 +++++++- 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/components/workspace/FormatQuickSelector.tsx b/src/components/workspace/FormatQuickSelector.tsx index 692eaac..2f9794e 100644 --- a/src/components/workspace/FormatQuickSelector.tsx +++ b/src/components/workspace/FormatQuickSelector.tsx @@ -1,4 +1,5 @@ import { useId, useState } from 'react' +import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react' import { InlineHelp } from '@/components/common/InlineHelp' import type { FormatType } from '@/lib/chrome-ai/types' import { FORMAT_OPTIONS, FORMAT_PRESETS, getFormatLabels } from '@/constants/formats' @@ -11,18 +12,34 @@ interface PreviewTooltipProps { const PreviewTooltip = ({ preview, label }: PreviewTooltipProps) => { const [show, setShow] = useState(false) + const handleOpen = () => setShow(true) + const handleClose = () => setShow(false) + const handleToggle = (event?: ReactMouseEvent | ReactKeyboardEvent) => { + if (event) { + event.stopPropagation() + event.preventDefault() + } + setShow((prev) => !prev) + } + return (
- +
{show && (
diff --git a/src/lib/content-extraction/readability-parser.test.ts b/src/lib/content-extraction/readability-parser.test.ts index cc7878a..24fd462 100644 --- a/src/lib/content-extraction/readability-parser.test.ts +++ b/src/lib/content-extraction/readability-parser.test.ts @@ -194,6 +194,26 @@ describe('readability-parser', () => { expect(Array.isArray(result.images)).toBe(true) }) + it('should resolve relative image sources against the final URL', async () => { + const html = ` + + +
+

Relative Image Test

+ Relative image +
+ + + ` + + const fetchResult = createFetchResult(html, 'https://example.com/posts/test-article') + const result = await parseContent(fetchResult) + + const relativeImage = result.images.find((img) => img.alt === 'Relative image') + expect(relativeImage).toBeDefined() + expect(relativeImage?.src).toBe('https://example.com/assets/relative-image.jpg') + }) + it('should handle articles with no images', async () => { const fetchResult = createFetchResult(articleWithNoImages) const result = await parseContent(fetchResult) diff --git a/src/lib/content-extraction/readability-parser.ts b/src/lib/content-extraction/readability-parser.ts index 2fec961..2fa3fe3 100644 --- a/src/lib/content-extraction/readability-parser.ts +++ b/src/lib/content-extraction/readability-parser.ts @@ -25,7 +25,38 @@ function countWords(text: string): number { /** * Extract images from HTML content */ -function extractImages(htmlContent: string): ExtractedImage[] { +function resolveImageSource(src: string, baseUrl: string): string | null { + const trimmed = src.trim() + if (!trimmed) { + return null + } + + // Allow data/blob URLs directly + if (/^(data:|blob:)/i.test(trimmed)) { + return trimmed + } + + // Protocol-relative URLs (e.g., //cdn.example.com/image.jpg) + if (trimmed.startsWith('//')) { + return `https:${trimmed}` + } + + try { + // Convert relative paths to absolute using the final page URL + const absoluteUrl = new URL(trimmed, baseUrl) + return absoluteUrl.toString() + } catch (error) { + reportError({ + error, + context: { operation: 'resolveImageSource', src: trimmed, baseUrl }, + severity: 'warning', + source: 'content-extraction:parser', + }) + return null + } +} + +function extractImages(htmlContent: string, baseUrl: string): ExtractedImage[] { const images: ExtractedImage[] = [] try { @@ -49,8 +80,13 @@ function extractImages(htmlContent: string): ExtractedImage[] { } } + const normalizedSrc = resolveImageSource(src, baseUrl) + if (!normalizedSrc) { + return + } + images.push({ - src: src.trim(), + src: normalizedSrc, alt: img.getAttribute('alt') || undefined, title: img.getAttribute('title') || undefined, }) @@ -147,7 +183,7 @@ export async function parseContent( }) // Extract images if requested - const images: ExtractedImage[] = preserveImages ? extractImages(sanitizedContent) : [] + const images: ExtractedImage[] = preserveImages ? extractImages(sanitizedContent, finalURL) : [] // Get text content for word count const textContent = article.textContent || '' diff --git a/src/pages/preferences/PreferencesPage.tsx b/src/pages/preferences/PreferencesPage.tsx index 735b21b..46bc349 100644 --- a/src/pages/preferences/PreferencesPage.tsx +++ b/src/pages/preferences/PreferencesPage.tsx @@ -13,6 +13,7 @@ import { LanguageSelector } from '@/components/input/LanguageSelector' import { LANGUAGE_OPTIONS } from '@/constants/languages' import { ThemeSelector } from '@/components/preferences' import { useTheme } from '@/hooks/useTheme' +import { useToast } from '@/hooks/useToast' const readingLevels = getAllReadingLevels() const ADULT_LEVEL = @@ -21,6 +22,7 @@ const VISIBLE_LANGUAGES = 12 export const PreferencesPage = () => { const { theme, setTheme } = useTheme() + const { showSuccess, showError } = useToast() const levelSet = useMemo( () => new Set(readingLevels.map((level) => level.level)), @@ -93,10 +95,13 @@ export const PreferencesPage = () => { // Validate format combination before saving if (!isValidFormatCombination(selectedFormats)) { console.error('Invalid format combination, cannot save') + showError('Invalid format selection', { + message: 'Please choose at least one format before saving.', + }) return } - persistPreferences({ + const saved = persistPreferences({ [PreferenceKey.ReadingLevel]: targetReadingLevel, [PreferenceKey.ShowComparison]: showComparison, [PreferenceKey.FormatSelection]: selectedFormats, @@ -104,12 +109,23 @@ export const PreferencesPage = () => { [PreferenceKey.TargetLanguage]: targetLanguage, [PreferenceKey.FavoriteLanguages]: favoriteLanguages, }) + if (!saved) { + showError('Preferences not saved', { + message: 'Something went wrong while saving. Please try again.', + }) + return + } setSavedReadingLevel(targetReadingLevel) setSavedShowComparison(showComparison) setSavedFormats(selectedFormats) setSavedPreset(selectedPreset) setSavedTargetLanguage(targetLanguage) setSavedFavoriteLanguages(favoriteLanguages) + + showSuccess('Preferences saved', { + message: 'Your defaults will now be used across Synapse.', + duration: 4000, + }) } const handleReset = () => { From 8c14d21cb1e00aef685c0c5f6bee6940f3fbf024 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Thu, 30 Oct 2025 22:19:04 +0200 Subject: [PATCH 5/9] feat: enhance progress tracking and UI feedback in output components - Updated FormattedOutput and MultiFormatProgress components to provide clearer progress information, including current format processing status. - Refactored progress calculation logic to account for partial steps and improve accuracy in displaying completion percentages. - Enhanced user feedback in the WorkspacePage to reflect real-time processing updates for selected formats. - Simplified format selection logic in FormatQuickSelector to ensure at least one format is always selected while improving code readability. --- src/components/output/FormattedOutput.tsx | 11 +++-- src/components/ui/MultiFormatProgress.tsx | 4 +- .../workspace/FormatQuickSelector.tsx | 43 ++++++------------- src/lib/chrome-ai/utils/progressCalculator.ts | 7 ++- src/pages/workspace/WorkspacePage.tsx | 6 ++- 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/src/components/output/FormattedOutput.tsx b/src/components/output/FormattedOutput.tsx index 3079be2..05c6d9d 100644 --- a/src/components/output/FormattedOutput.tsx +++ b/src/components/output/FormattedOutput.tsx @@ -161,13 +161,18 @@ export const FormattedOutput = ({ const hasLoadingInfo = Boolean(isLoading && isLoading.size > 0) const isInProgress = hasLoadingInfo && completed < total - const percent = total > 0 ? (completed / total) * 100 : 0 + const partialStep = isInProgress && active ? 0.5 : 0 + const percent = total > 0 ? ((completed + partialStep) / total) * 100 : 0 + const currentPosition = isInProgress + ? Math.min(completed + 1, total) + : total return { total, completed, active: isInProgress ? active : undefined, isInProgress, + currentPosition, percent, } }, [formats, isLoading, results]) @@ -175,7 +180,7 @@ export const FormattedOutput = ({ const formatCountLabel = useMemo(() => { const suffix = progress.total !== 1 ? 's' : '' if (progress.isInProgress) { - return `${progress.completed} of ${progress.total} format${suffix} complete` + return `Currently processing format ${progress.currentPosition} of ${progress.total}` } return `${progress.total} format${suffix} generated` }, [progress]) @@ -207,7 +212,7 @@ export const FormattedOutput = ({

- Generating Formats ({progress.completed}/{progress.total}) + Generating Formats (Format {progress.currentPosition} of {progress.total})

{progress.active && `Currently processing: ${getFormatLabels([progress.active])}`} diff --git a/src/components/ui/MultiFormatProgress.tsx b/src/components/ui/MultiFormatProgress.tsx index a0dd1cc..bf81bc5 100644 --- a/src/components/ui/MultiFormatProgress.tsx +++ b/src/components/ui/MultiFormatProgress.tsx @@ -111,7 +111,9 @@ export const MultiFormatProgress = ({ Overall Progress - {aggregate.completedCount} of {aggregate.totalCount} formats + {aggregate.currentFormat + ? `Format ${Math.min(aggregate.completedCount + 1, aggregate.totalCount)} of ${aggregate.totalCount}` + : `${aggregate.completedCount} of ${aggregate.totalCount} formats complete`}

{ const isSelected = selectedFormats.includes(type) - // If toggling summary - if (type === 'summary') { - if (isSelected) { - // Deselecting summary - default to bullets - onChangeFormats(['bullets']) - } else { - // Selecting summary - replace all other formats - onChangeFormats(['summary']) + let updatedFormats: FormatType[] + + if (isSelected) { + const next = selectedFormats.filter((format) => format !== type) + + // Keep at least one format selected + if (next.length === 0) { + return } + + updatedFormats = next } else { - // Toggling a non-summary format - if (isSelected) { - // Deselecting this format - prevent empty selection - const newFormats = selectedFormats.filter((f) => f !== type) - if (newFormats.length > 0) { - onChangeFormats(newFormats) - } - } else { - // Selecting this format - remove summary if present - const newFormats = selectedFormats.filter((f) => f !== 'summary') - onChangeFormats([...newFormats, type]) - } + updatedFormats = [...selectedFormats, type] } + + onChangeFormats(updatedFormats) } const handlePresetClick = (formats: FormatType[]) => { @@ -231,23 +224,15 @@ export const FormatQuickSelector = ({
{Object.values(FORMAT_OPTIONS).map((config) => { const isSelected = selectedFormats.includes(config.type) - const isDisabled = - config.type !== 'summary' && - selectedFormats.includes('summary') && - !isSelected - return ( - ) : ( - <> -
- - {selectedFormats.length === 0 && ( -

- ⚠️ Please select at least one output format above -

- )} -
- + + + Extract text + + ) : ( + <> +
- - )} + {selectedFormats.length === 0 && ( +

+ ⚠️ Please select at least one output format above +

+ )} +
- -
- - {fileUpload.files.some((f) => f.status === 'complete') && ( -
-
{ strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} - d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> -
-

- Text extraction complete -

-

- Extracted {fileUpload.getAllExtractedText().length.toLocaleString()} characters from {fileUpload.files.filter((f) => f.status === 'complete').length} file(s). Click "Use extracted text" to process. -

-
+ Edit as text + + + )} + + +
+ + {fileUpload.files.some((f) => f.status === 'complete') && ( +
+
+ +
+

+ Text extraction complete +

+

+ Extracted {fileUpload.getAllExtractedText().length.toLocaleString()} characters from {fileUpload.files.filter((f) => f.status === 'complete').length} file(s). Click "Use extracted text" to process. +

- )} - - )} +
+ )} + + )}
) : inputMode === 'writing-tools' ? (
-
-

- Chrome writing tools (preview) -

-

- Proofread drafts, adjust tone, or expand copy with Chrome's on-device AI. Pick a template or jump back into the text workspace to run a full transformation. -

-
-
- {writingToolHighlights.map((item) => ( -
-

- {item.title} -

-

{item.description}

-
- ))} -
-
- - -
+

+ {item.title} +

+

{item.description}

+
+ ))} +
+
+ + +
) : (
-
-

- Extract from URL -

-

- Enter a URL to extract and process article content -

-
- +
+

+ Extract from URL +

+

+ Enter a URL to extract and process article content +

+
+ - {content && ( -
- -
- )} + {content && ( +
+ +
+ )} - {!content && ( -
-

- What we extract -

-
    -
  • - - Main article text without ads or navigation -
  • -
  • - - Images with alt text and captions -
  • -
  • - - Article metadata (author, reading time, word count) -
  • -
  • - - Cleaned and sanitized HTML for security -
  • -
-
- )} + {!content && ( +
+

+ What we extract +

+
    +
  • + + Main article text without ads or navigation +
  • +
  • + + Images with alt text and captions +
  • +
  • + + Article metadata (author, reading time, word count) +
  • +
  • + + Cleaned and sanitized HTML for security +
  • +
+
+ )}
)}
From 392a9f59ffbd082363f5e54cdba42c62cd697659 Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Fri, 31 Oct 2025 17:06:05 +0200 Subject: [PATCH 8/9] fix: update progress indicators in integration tests for accuracy - Adjusted progress indicator text in component integration tests to reflect the correct format being processed. - Enhanced clarity in test assertions for currently processing formats in FormattedOutput and MultiFormatProgress components. - Ensured consistency in progress tracking across various components to improve user feedback during processing. --- src/__tests__/integration/component-integration.test.tsx | 7 +++---- src/components/output/FormattedOutput.test.tsx | 3 ++- src/components/ui/__tests__/ProgressComponents.test.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/integration/component-integration.test.tsx b/src/__tests__/integration/component-integration.test.tsx index 9a5736b..1e18295 100644 --- a/src/__tests__/integration/component-integration.test.tsx +++ b/src/__tests__/integration/component-integration.test.tsx @@ -96,12 +96,11 @@ describe('Component Integration Tests', () => { /> ) - // Should show progress indicator - expect(screen.getByText(/Generating Formats \(Format/)).toBeInTheDocument() - expect(screen.getByText(/Format 1 of 3/)).toBeInTheDocument() + // Should show progress indicator (bullets complete, so showing format 2 of 3) + expect(screen.getByText(/Generating Formats \(Format 2 of 3\)/)).toBeInTheDocument() // Should show currently processing format - expect(screen.getByText(/Currently processing: paragraphs/)).toBeInTheDocument() + expect(screen.getByText(/Currently processing:/)).toBeInTheDocument() }) it('should handle clear functionality', async () => { diff --git a/src/components/output/FormattedOutput.test.tsx b/src/components/output/FormattedOutput.test.tsx index bc83d99..348de76 100644 --- a/src/components/output/FormattedOutput.test.tsx +++ b/src/components/output/FormattedOutput.test.tsx @@ -113,7 +113,8 @@ describe('FormattedOutput', () => { isLoading={isLoading} /> ) - expect(screen.getByText(/1\/3/)).toBeInTheDocument() + // 1 complete, so showing Format 2 of 3 + expect(screen.getByText(/Format 2 of 3/)).toBeInTheDocument() }) it('should show currently processing format', () => { diff --git a/src/components/ui/__tests__/ProgressComponents.test.tsx b/src/components/ui/__tests__/ProgressComponents.test.tsx index b245c8d..7953034 100644 --- a/src/components/ui/__tests__/ProgressComponents.test.tsx +++ b/src/components/ui/__tests__/ProgressComponents.test.tsx @@ -215,7 +215,7 @@ describe('ProgressComponents', () => { render() expect(screen.getByText('Overall Progress')).toBeInTheDocument() - expect(screen.getByText('1 of 3 formats')).toBeInTheDocument() + expect(screen.getByText('Format 2 of 3')).toBeInTheDocument() expect(screen.getByText(/currently processing: bullet points/i)).toBeInTheDocument() }) From 3d8dab7621ceac1bce659244210b47882fd40d1e Mon Sep 17 00:00:00 2001 From: Shingirayi Mandebvu Date: Fri, 31 Oct 2025 17:11:33 +0200 Subject: [PATCH 9/9] refactor: streamline chunk processing in FormatTransformService - Removed unnecessary chunk count tracking in the transformToFormatStreaming method to simplify the code. - Enhanced clarity in the chunk processing logic for improved maintainability. --- src/lib/chrome-ai/services/FormatTransformService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/chrome-ai/services/FormatTransformService.ts b/src/lib/chrome-ai/services/FormatTransformService.ts index 3b4389c..0c2099b 100644 --- a/src/lib/chrome-ai/services/FormatTransformService.ts +++ b/src/lib/chrome-ai/services/FormatTransformService.ts @@ -349,11 +349,9 @@ export class FormatTransformService { } let fullContent = '' - let chunkCount = 0 for await (const chunk of this.transformToFormatStreaming(content, format, options)) { fullContent += chunk - chunkCount++ // Always split chunks for consistent streaming (more aggressive) // Even small chunks get split for smooth progressive display if (chunk.length > 20) {