From ab2cf24556034b9ec342b48f8223c16028f50593 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 22 Feb 2026 02:17:54 +0200 Subject: [PATCH 1/2] feat(qa): improve empty state copy and animate no-questions message --- frontend/app/globals.css | 46 ++++++++++++++++++- frontend/components/q&a/AccordionList.tsx | 26 ++++++++--- .../components/q&a/FloatingExplainButton.tsx | 2 + frontend/components/q&a/QaSection.tsx | 26 ++++++++++- .../components/tests/q&a/qa-section.test.tsx | 1 + frontend/components/ui/accordion.tsx | 7 ++- frontend/messages/en.json | 4 +- frontend/messages/pl.json | 4 +- frontend/messages/uk.json | 4 +- 9 files changed, 103 insertions(+), 17 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8a08fd99..e0608abe 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -183,7 +183,24 @@ html { .qa-accordion-item:hover, .qa-accordion-item:focus-within, .qa-accordion-item[data-state='open'] { - border-color: var(--qa-accent); + border-color: var(--qa-accent, var(--accent-primary)); +} + +.qa-accordion-item { + position: relative; + overflow: hidden; + background-image: linear-gradient( + 90deg, + transparent 0%, + transparent 54%, + var(--qa-accent-soft, rgba(161, 161, 170, 0.22)) 100% + ); +} + +.qa-accordion-item:hover, +.qa-accordion-item:focus-within, +.qa-accordion-item[data-state='open'] { + background-image: none; } .no-select, @@ -195,6 +212,31 @@ html { user-select: none; } +@keyframes fade-up { + from { + transform: translateY(8px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.animate-fade-up { + animation: fade-up 0.6s ease-out forwards; + opacity: 0; +} + +.delay-150 { + animation-delay: 0.15s; +} + +.delay-300 { + animation-delay: 0.3s; +} + @keyframes progress { from { width: 100%; @@ -535,4 +577,4 @@ html { .animate-dash-flow { animation: none !important; } -} \ No newline at end of file +} diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx index 6d61b035..e603388e 100644 --- a/frontend/components/q&a/AccordionList.tsx +++ b/frontend/components/q&a/AccordionList.tsx @@ -36,6 +36,19 @@ import { import { categoryTabStyles } from '@/data/categoryStyles'; import { CACHE_KEY, getCachedTerms } from '@/lib/ai/explainCache'; +function normalizeCachedTerm(term: string): string { + return term.toLowerCase().trim(); +} + +function hexToRgba(hex: string, alpha: number): string { + const normalized = hex.replace('#', ''); + if (normalized.length !== 6) return `rgba(0, 0, 0, ${alpha})`; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + function isListItemBlock(value: ListEntry): value is ListItemBlock { return ( !!value && @@ -307,11 +320,11 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { ); const [modalOpen, setModalOpen] = useState(false); const [cachedTerms, setCachedTerms] = useState>( - () => new Set(getCachedTerms()) + () => new Set(getCachedTerms().map(normalizeCachedTerm)) ); const refreshCachedTerms = useCallback(() => { - const terms = getCachedTerms(); + const terms = getCachedTerms().map(normalizeCachedTerm); setCachedTerms(new Set(terms)); }, []); @@ -380,20 +393,21 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { {items.map((q, idx) => { const key = q.id ?? idx; - const accent = + const accentColor = categoryTabStyles[q.category as keyof typeof categoryTabStyles] - ?.accent; + ?.accent ?? '#A1A1AA'; const animationDelay = `${Math.min(idx, 10) * 60}ms`; const itemStyle: CSSProperties = { animationDelay, animationFillMode: 'both', - ...(accent ? ({ '--qa-accent': accent } as CSSProperties) : {}), + '--qa-accent': accentColor, + '--qa-accent-soft': hexToRgba(accentColor, 0.22), }; return ( { e.stopPropagation(); @@ -67,6 +68,7 @@ export default function FloatingExplainButton({ 'hover:bg-(--accent-hover)', 'transition-all duration-200', 'animate-in fade-in-0 zoom-in-95', + 'motion-reduce:animate-none motion-reduce:transition-none', 'focus:ring-2 focus:ring-(--accent-primary) focus:ring-offset-2 focus:outline-none' )} style={{ diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx index b7b824c5..93dbfd3d 100644 --- a/frontend/components/q&a/QaSection.tsx +++ b/frontend/components/q&a/QaSection.tsx @@ -35,6 +35,14 @@ export default function TabsSection() { () => `qa-${active}-${currentPage}`, [active, currentPage] ); + const emptyStateLines = useMemo( + () => + t('noQuestions') + .split('\n') + .map(line => line.trim()) + .filter(Boolean), + [t] + ); const clearSelection = useCallback(() => { if (typeof window === 'undefined') return; @@ -109,7 +117,23 @@ export default function TabsSection() { {items.length ? ( ) : ( -

{t('noQuestions')}

+
+ {emptyStateLines[0] && ( +

+ {emptyStateLines[0]} +

+ )} + {emptyStateLines[1] && ( +

+ {emptyStateLines[1]} +

+ )} + {emptyStateLines[2] && ( +

+ {emptyStateLines[2]} +

+ )} +
)} diff --git a/frontend/components/tests/q&a/qa-section.test.tsx b/frontend/components/tests/q&a/qa-section.test.tsx index 60faa0d7..be0c042d 100644 --- a/frontend/components/tests/q&a/qa-section.test.tsx +++ b/frontend/components/tests/q&a/qa-section.test.tsx @@ -60,6 +60,7 @@ describe('QaSection', () => { it('renders category tabs and pagination', () => { qaState.totalPages = 3; + qaState.items = [{ id: 'q1' }]; render(); const buttons = screen.getAllByRole('button'); diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index b3cb453a..c932894f 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -41,7 +41,10 @@ function AccordionTrigger({ {...props} > {children} - +