From edc400c0da09e3a60b13912df88d01b2be291a94 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:50:20 +0000 Subject: [PATCH 1/2] I've updated the Palette project with several accessibility and loading state improvements: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎨 Palette: Accessibility and loading state improvements - Added ARIA labels to icon-only buttons in the Header, ChatPanel, MobileIconsBar, and History components for better accessibility. - Implemented a loading state with a Spinner in the chat interface to provide visual feedback while messages are being sent. - Reused and consolidated the existing messaging state in the Chat component. - Verified changes with Playwright and ensured the build and linting checks pass. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- .Jules/palette.md | 7 ++++ components/chat-panel.tsx | 73 +++++++++++++++++++-------------- components/chat.tsx | 15 +++++-- components/header.tsx | 8 ++-- components/history.tsx | 1 + components/mobile-icons-bar.tsx | 18 ++++---- 6 files changed, 75 insertions(+), 47 deletions(-) create mode 100644 .Jules/palette.md diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..5cc74751 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,7 @@ +## 2025-05-14 - [Accessibility Audit & Fix for Icon Buttons] +**Learning:** Icon-only buttons without aria-labels are common accessibility gaps in this project. Adding them consistently across Core UI (Header, ChatPanel, MobileIconsBar) significantly improves the experience for screen reader users without visual clutter. +**Action:** Always check icon-only buttons for aria-label or title attributes when modifying UI. + +## 2025-05-14 - [Loading Feedback for Submissions] +**Learning:** Reusing existing state (like isSubmitting) to provide visual feedback (Spinner) is preferred over creating redundant states. It keeps the architecture clean and prevents synchronization issues. +**Action:** Look for existing submission/loading states in parent components before introducing new ones in child components. diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index ca2fbc6f..c0858a3e 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' import { UserMessage } from './user-message' import { Button } from './ui/button' import { ArrowRight, Plus, Paperclip, X, Sprout } from 'lucide-react' +import { Spinner } from './ui/spinner' import Textarea from 'react-textarea-autosize' import { nanoid } from '@/lib/utils' import { useSettingsStore } from '@/lib/store/settings' @@ -20,6 +21,8 @@ interface ChatPanelProps { input: string setInput: (value: string) => void onSuggestionsChange?: (suggestions: PartialRelated | null) => void + isPending?: boolean + setIsPending?: (pending: boolean) => void } export interface ChatPanelRef { @@ -27,7 +30,7 @@ export interface ChatPanelRef { submitForm: () => void } -export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { +export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange, isPending, setIsPending }, ref) => { const [, setMessages] = useUIState() const { submit, clearChat } = useActions() const { mapProvider } = useSettingsStore() @@ -87,42 +90,48 @@ export const ChatPanel = forwardRef(({ messages, i const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!input.trim() && !selectedFile) { + if ((!input.trim() && !selectedFile) || isPending) { return } - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] - if (input) { - content.push({ type: 'text', text: input }) - } - if (selectedFile && selectedFile.type.startsWith('image/')) { - content.push({ - type: 'image', - image: URL.createObjectURL(selectedFile) - }) - } + setIsPending?.(true) - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - component: + try { + const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] + if (input) { + content.push({ type: 'text', text: input }) + } + if (selectedFile && selectedFile.type.startsWith('image/')) { + content.push({ + type: 'image', + image: URL.createObjectURL(selectedFile) + }) } - ]) - const formData = new FormData(e.currentTarget) - if (selectedFile) { - formData.append('file', selectedFile) - } + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + component: + } + ]) - // Include drawn features in the form data - formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) + const formData = new FormData(e.currentTarget) + if (selectedFile) { + formData.append('file', selectedFile) + } - setInput('') - clearAttachment() + // Include drawn features in the form data + formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) - const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) + setInput('') + clearAttachment() + + const responseMessage = await submit(formData) + setMessages(currentMessages => [...currentMessages, responseMessage as any]) + } finally { + setIsPending?.(false) + } } const handleClear = async () => { @@ -177,6 +186,7 @@ export const ChatPanel = forwardRef(({ messages, i onClick={() => handleClear()} data-testid="new-chat-button" title="New Chat" + aria-label="Start new chat" > @@ -225,6 +235,7 @@ export const ChatPanel = forwardRef(({ messages, i )} onClick={handleAttachmentClick} data-testid="desktop-attachment-button" + aria-label="Attach file" > @@ -281,11 +292,11 @@ export const ChatPanel = forwardRef(({ messages, i 'absolute top-1/2 transform -translate-y-1/2', isMobile ? 'right-1' : 'right-2' )} - disabled={input.length === 0 && !selectedFile} + disabled={(input.length === 0 && !selectedFile) || isPending} aria-label="Send message" data-testid="chat-submit" > - + {isPending ? : } @@ -295,7 +306,7 @@ export const ChatPanel = forwardRef(({ messages, i {selectedFile.name} - diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..d666b2ce 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -86,9 +86,8 @@ export function Chat({ id }: ChatProps) { const { mapData } = useMapData(); useEffect(() => { - if (isSubmitting) { - chatPanelRef.current?.submitForm() - setIsSubmitting(false) + if (isSubmitting && chatPanelRef.current) { + chatPanelRef.current.submitForm() } }, [isSubmitting]) @@ -132,7 +131,11 @@ export function Chat({ id }: ChatProps) { {activeView ? : isUsageOpen ? : }
- +
@@ -185,6 +190,8 @@ export function Chat({ id }: ChatProps) { input={input} setInput={setInput} onSuggestionsChange={setSuggestions} + isPending={isSubmitting} + setIsPending={setIsSubmitting} />
diff --git a/components/header.tsx b/components/header.tsx index fd80bc44..b144830c 100644 --- a/components/header.tsx +++ b/components/header.tsx @@ -52,7 +52,7 @@ export const Header = () => {
-
- @@ -89,7 +89,7 @@ export const Header = () => { {/* Mobile menu buttons */}
- diff --git a/components/history.tsx b/components/history.tsx index 5bae1a39..8977a037 100644 --- a/components/history.tsx +++ b/components/history.tsx @@ -19,6 +19,7 @@ export function History({ location }: HistoryProps) { })} data-testid="history-button" onClick={toggleHistory} + aria-label={location === 'header' ? "Toggle history" : "Close history"} > {location === 'header' ? : } diff --git a/components/mobile-icons-bar.tsx b/components/mobile-icons-bar.tsx index d0db2cfa..1c4497c9 100644 --- a/components/mobile-icons-bar.tsx +++ b/components/mobile-icons-bar.tsx @@ -19,13 +19,15 @@ import { MapToggle } from './map-toggle' import { ModeToggle } from './mode-toggle' import { ProfileToggle } from './profile-toggle' import { useCalendarToggle } from './calendar-toggle-context' +import { Spinner } from './ui/spinner' interface MobileIconsBarProps { onAttachmentClick: () => void; onSubmitClick: () => void; + isPending?: boolean; } -export const MobileIconsBar: React.FC = ({ onAttachmentClick, onSubmitClick }) => { +export const MobileIconsBar: React.FC = ({ onAttachmentClick, onSubmitClick, isPending }) => { const [, setMessages] = useUIState() const { clearChat } = useActions() const { toggleCalendar } = useCalendarToggle() @@ -37,27 +39,27 @@ export const MobileIconsBar: React.FC = ({ onAttachmentClic return (
- - - - - - From df93ac21da50189f159c4234efc6941740b33ae5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:40:10 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Comprehensive=20U?= =?UTF-8?q?X,=20accessibility,=20and=20state=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I have implemented several improvements to the interface and underlying logic: - Updated the project documentation in .Jules/palette.md with 2026 dates and improved formatting. - Decoupled the sending trigger from the pending state in Chat.tsx using a counter for better reliability. - Resolved a memory leak in ChatPanel by properly tracking and revoking blob URLs. - Enhanced error handling in ChatPanel to ensure input and attachment states are restored and optimistic messages are removed upon failure. - Synchronized the interaction state of the send button with the message handler to ensure UI consistency. - Fixed the Stripe checkout link in the mobile navigation bar to resolve nested interactive elements and improve ARIA labeling. - Updated the UserMessage and ChatPanel types to better support optimistic image tracking. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- .Jules/palette.md | 6 +++-- components/chat-panel.tsx | 41 +++++++++++++++++++++++++++++---- components/chat.tsx | 11 +++++---- components/mobile-icons-bar.tsx | 15 ++++++++---- components/user-message.tsx | 2 +- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 5cc74751..81ca2dc8 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -1,7 +1,9 @@ -## 2025-05-14 - [Accessibility Audit & Fix for Icon Buttons] +# 2026-02-15 - [Accessibility Audit & Fix for Icon Buttons] + **Learning:** Icon-only buttons without aria-labels are common accessibility gaps in this project. Adding them consistently across Core UI (Header, ChatPanel, MobileIconsBar) significantly improves the experience for screen reader users without visual clutter. **Action:** Always check icon-only buttons for aria-label or title attributes when modifying UI. -## 2025-05-14 - [Loading Feedback for Submissions] +## 2026-02-15 - [Loading Feedback for Submissions] + **Learning:** Reusing existing state (like isSubmitting) to provide visual feedback (Spinner) is preferred over creating redundant states. It keeps the architecture clean and prevents synchronization issues. **Action:** Look for existing submission/loading states in parent components before introducing new ones in child components. diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c0858a3e..a7d184fc 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -46,6 +46,15 @@ export const ChatPanel = forwardRef(({ messages, i const inputRef = useRef(null) const formRef = useRef(null) const fileInputRef = useRef(null) + const objectUrls = useRef>(new Set()) + + useEffect(() => { + const urls = objectUrls.current + return () => { + urls.forEach(url => URL.revokeObjectURL(url)) + urls.clear() + } + }, []) useImperativeHandle(ref, () => ({ handleAttachmentClick() { @@ -96,22 +105,31 @@ export const ChatPanel = forwardRef(({ messages, i setIsPending?.(true) + const userMessageId = nanoid() + const currentInput = input + const currentFile = selectedFile + const createdUrls: string[] = [] + try { - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] + const content: ({ type: 'text'; text: string } | { type: 'image'; image: string; isOptimistic?: boolean })[] = [] if (input) { content.push({ type: 'text', text: input }) } if (selectedFile && selectedFile.type.startsWith('image/')) { + const url = URL.createObjectURL(selectedFile) + createdUrls.push(url) + objectUrls.current.add(url) content.push({ type: 'image', - image: URL.createObjectURL(selectedFile) + image: url, + isOptimistic: true }) } setMessages(currentMessages => [ ...currentMessages, { - id: nanoid(), + id: userMessageId, component: } ]) @@ -129,6 +147,21 @@ export const ChatPanel = forwardRef(({ messages, i const responseMessage = await submit(formData) setMessages(currentMessages => [...currentMessages, responseMessage as any]) + + // Revoke URLs after upload finishes + createdUrls.forEach(url => { + URL.revokeObjectURL(url) + objectUrls.current.delete(url) + }) + } catch (error) { + console.error('Failed to submit message:', error) + setMessages(currentMessages => currentMessages.filter(m => m.id !== userMessageId)) + setInput(currentInput) + setSelectedFile(currentFile) + createdUrls.forEach(url => { + URL.revokeObjectURL(url) + objectUrls.current.delete(url) + }) } finally { setIsPending?.(false) } @@ -292,7 +325,7 @@ export const ChatPanel = forwardRef(({ messages, i 'absolute top-1/2 transform -translate-y-1/2', isMobile ? 'right-1' : 'right-2' )} - disabled={(input.length === 0 && !selectedFile) || isPending} + disabled={(!input.trim() && !selectedFile) || isPending} aria-label="Send message" data-testid="chat-submit" > diff --git a/components/chat.tsx b/components/chat.tsx index d666b2ce..2374f5de 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -38,6 +38,7 @@ export function Chat({ id }: ChatProps) { const [input, setInput] = useState('') const [showEmptyScreen, setShowEmptyScreen] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const [triggerSubmit, setTriggerSubmit] = useState(0) const [suggestions, setSuggestions] = useState(null) const chatPanelRef = useRef(null); @@ -86,10 +87,10 @@ export function Chat({ id }: ChatProps) { const { mapData } = useMapData(); useEffect(() => { - if (isSubmitting && chatPanelRef.current) { + if (triggerSubmit > 0 && chatPanelRef.current) { chatPanelRef.current.submitForm() } - }, [isSubmitting]) + }, [triggerSubmit]) // useEffect to call the server action when drawnFeatures changes useEffect(() => { @@ -112,7 +113,7 @@ export function Chat({ id }: ChatProps) { setInput(query) setSuggestions(null) // Use a small timeout to ensure state update before submission - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} onClose={() => setSuggestions(null)} className="relative bottom-auto mb-0 w-full shadow-none border-none bg-transparent" @@ -158,7 +159,7 @@ export function Chat({ id }: ChatProps) { { setInput(message) - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} /> ) : ( @@ -199,7 +200,7 @@ export function Chat({ id }: ChatProps) { { setInput(message) - setIsSubmitting(true) + setTriggerSubmit(prev => prev + 1) }} /> ) : ( diff --git a/components/mobile-icons-bar.tsx b/components/mobile-icons-bar.tsx index 1c4497c9..cec6054e 100644 --- a/components/mobile-icons-bar.tsx +++ b/components/mobile-icons-bar.tsx @@ -3,7 +3,8 @@ import React from 'react' import { useUIState, useActions } from 'ai/rsc' import { AI } from '@/app/actions' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' import { Search, CircleUserRound, @@ -50,10 +51,14 @@ export const MobileIconsBar: React.FC = ({ onAttachmentClic - - + +