Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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%;
Expand Down Expand Up @@ -535,4 +577,4 @@ html {
.animate-dash-flow {
animation: none !important;
}
}
}
33 changes: 26 additions & 7 deletions frontend/components/q&a/AccordionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ import {
import { categoryTabStyles } from '@/data/categoryStyles';
import { CACHE_KEY, getCachedTerms } from '@/lib/ai/explainCache';

type QaItemStyle = CSSProperties & {
'--qa-accent': string;
'--qa-accent-soft': string;
};

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 &&
Expand Down Expand Up @@ -307,11 +325,11 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
);
const [modalOpen, setModalOpen] = useState(false);
const [cachedTerms, setCachedTerms] = useState<Set<string>>(
() => new Set(getCachedTerms())
() => new Set(getCachedTerms().map(normalizeCachedTerm))
);

const refreshCachedTerms = useCallback(() => {
const terms = getCachedTerms();
const terms = getCachedTerms().map(normalizeCachedTerm);
setCachedTerms(new Set(terms));
}, []);

Expand Down Expand Up @@ -380,20 +398,21 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
<Accordion type="single" collapsible className="w-full">
{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 = {
const itemStyle: QaItemStyle = {
animationDelay,
animationFillMode: 'both',
...(accent ? ({ '--qa-accent': accent } as CSSProperties) : {}),
'--qa-accent': accentColor,
'--qa-accent-soft': hexToRgba(accentColor, 0.22),
};
return (
<AccordionItem
key={key}
value={String(key)}
className="qa-accordion-item mb-3 rounded-xl border border-black/5 bg-white/90 shadow-sm transition-colors last:mb-0 last:border-b dark:border-white/10 dark:bg-neutral-900/80 animate-in fade-in slide-in-from-bottom-2 duration-500"
className="qa-accordion-item mb-3 rounded-xl border border-black/5 bg-white/90 shadow-sm transition-colors last:mb-0 last:border-b dark:border-white/10 dark:bg-neutral-900/80 animate-in fade-in slide-in-from-bottom-2 duration-500 motion-reduce:animate-none"
style={itemStyle}
>
<AccordionTrigger
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/q&a/FloatingExplainButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default function FloatingExplainButton({

return (
<button
type="button"
ref={buttonRef}
onClick={e => {
e.stopPropagation();
Expand All @@ -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={{
Expand Down
26 changes: 25 additions & 1 deletion frontend/components/q&a/QaSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,7 +117,23 @@ export default function TabsSection() {
{items.length ? (
<AccordionList key={animationKey} items={items} />
) : (
<p className="py-12 text-center">{t('noQuestions')}</p>
<div className="py-20 text-center">
{emptyStateLines[0] && (
<p className="text-lg font-semibold text-gray-900 motion-safe:animate-fade-up motion-reduce:opacity-100 dark:text-white">
{emptyStateLines[0]}
</p>
)}
{emptyStateLines[1] && (
<p className="mt-2 text-gray-400 motion-safe:animate-fade-up motion-safe:[animation-delay:150ms] motion-reduce:opacity-100 dark:text-gray-300">
{emptyStateLines[1]}
</p>
)}
{emptyStateLines[2] && (
<p className="mt-1 text-gray-500 motion-safe:animate-fade-up motion-safe:[animation-delay:300ms] motion-reduce:opacity-100 dark:text-gray-400">
{emptyStateLines[2]}
</p>
)}
</div>
Comment on lines +120 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing role="status" — empty-state appearance is not announced to screen readers.

The empty state appears dynamically inside a tab panel when results are absent. Without a live region, screen readers will not announce the content change. Adding role="status" (implicit aria-live="polite") is the minimal, non-disruptive fix.

♿ Proposed fix
-              <div className="py-20 text-center">
+              <div role="status" className="py-20 text-center">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="py-20 text-center">
{emptyStateLines[0] && (
<p className="text-lg font-semibold text-gray-900 motion-safe:animate-fade-up motion-reduce:opacity-100 dark:text-white">
{emptyStateLines[0]}
</p>
)}
{emptyStateLines[1] && (
<p className="mt-2 text-gray-400 motion-safe:animate-fade-up motion-safe:[animation-delay:150ms] motion-reduce:opacity-100 dark:text-gray-300">
{emptyStateLines[1]}
</p>
)}
{emptyStateLines[2] && (
<p className="mt-1 text-gray-500 motion-safe:animate-fade-up motion-safe:[animation-delay:300ms] motion-reduce:opacity-100 dark:text-gray-400">
{emptyStateLines[2]}
</p>
)}
</div>
<div role="status" className="py-20 text-center">
{emptyStateLines[0] && (
<p className="text-lg font-semibold text-gray-900 motion-safe:animate-fade-up motion-reduce:opacity-100 dark:text-white">
{emptyStateLines[0]}
</p>
)}
{emptyStateLines[1] && (
<p className="mt-2 text-gray-400 motion-safe:animate-fade-up motion-safe:[animation-delay:150ms] motion-reduce:opacity-100 dark:text-gray-300">
{emptyStateLines[1]}
</p>
)}
{emptyStateLines[2] && (
<p className="mt-1 text-gray-500 motion-safe:animate-fade-up motion-safe:[animation-delay:300ms] motion-reduce:opacity-100 dark:text-gray-400">
{emptyStateLines[2]}
</p>
)}
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/q`&a/QaSection.tsx around lines 120 - 136, The
empty-state container in QaSection (the div rendering emptyStateLines) is not
announced to screen readers; add role="status" to that div (which implies
aria-live="polite") so dynamic empty-state text (emptyStateLines[0..2]) inside
the QaSection component is announced when it appears; update the div that
contains the three <p> elements to include role="status" and ensure no other
conflicting ARIA live attributes are present.

⚠️ Potential issue | 🟡 Minor

Previous animation-delay and dark-mode issues are resolved; stagger will fire silently during loading.

The motion-safe:[animation-delay:150ms/300ms] arbitrary-property syntax is valid Tailwind v3/v4 and correctly targets animation-delay inside the prefers-reduced-motion: no-preference media query. dark:text-gray-300 / dark:text-gray-400 are also in place — both previous review items are closed.

However, the empty state <div> has no key. When the user switches between tabs that all return empty results, React reuses the same DOM node and never re-mounts it. The three animate-fade-up animations fire once at initial mount — while the outer container is still opacity-0 (isLoading === true). By the time loading completes and the container fades back in, the stagger sequence has already expired and the user sees all three lines appear at once at full opacity.

Adding key={animationKey} forces a re-mount whenever the tab or page changes, restarting the stagger on every transition to an empty state.

🐛 Proposed fix — add key to the empty state container
-              <div className="py-20 text-center">
+              <div key={animationKey} className="py-20 text-center">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/components/q`&a/QaSection.tsx around lines 120 - 136, The
empty-state container with className "py-20 text-center" reuses the same DOM
node so the staggered animate-fade-up sequence (applied to the three paragraphs
rendered from emptyStateLines) only runs on initial mount and not on subsequent
tab/page changes; add a key prop (e.g. key={animationKey}) to that outer div and
derive animationKey from the thing that changes between views (selected tab id,
page id, or a combination with isLoading) so the container remounts whenever the
tab/page changes and the animation/stagger restarts.

)}
</div>
</TabsContent>
Expand Down
1 change: 1 addition & 0 deletions frontend/components/tests/q&a/qa-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ describe('QaSection', () => {

it('renders category tabs and pagination', () => {
qaState.totalPages = 3;
qaState.items = [{ id: 'q1' }];
render(<QaSection />);

const buttons = screen.getAllByRole('button');
Expand Down
7 changes: 5 additions & 2 deletions frontend/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ function AccordionTrigger({
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
<ChevronDownIcon
aria-hidden="true"
className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
Expand All @@ -55,7 +58,7 @@ function AccordionContent({
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down motion-reduce:!animate-none overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"searchPlaceholder": "Search questions...",
"clearSearch": "Clear search",
"noResults": "Nothing found for \"{query}\"",
"noQuestions": "No questions found",
"noQuestions": "No questions yet…\nBut they’re already in development.\nStay tuned - new content coming soon!",
"metaTitle": "Q&A | DevLovers",
"metaDescription": "Questions and answers for technical interview preparation",
"frontend": "Frontend",
Expand Down Expand Up @@ -1477,4 +1477,4 @@
}
}
}
}
}
4 changes: 2 additions & 2 deletions frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"searchPlaceholder": "Szukaj pytań...",
"clearSearch": "Wyczyść wyszukiwanie",
"noResults": "Nic nie znaleziono dla \"{query}\"",
"noQuestions": "Nie znaleziono pytań",
"noQuestions": "Brak pytań na ten moment…\nAle już są w przygotowaniu.\nBądź na bieżąco - wkrótce pojawią się nowe materiały!",
"metaTitle": "Q&A | DevLovers",
"metaDescription": "Pytania i odpowiedzi do przygotowania do rozmów kwalifikacyjnych",
"frontend": "Frontend",
Expand Down Expand Up @@ -1478,4 +1478,4 @@
}
}
}
}
}
4 changes: 2 additions & 2 deletions frontend/messages/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
"searchPlaceholder": "Пошук...",
"clearSearch": "Очистити пошук",
"noResults": "Нічого не знайдено за запитом \"{query}\"",
"noQuestions": "Питань не знайдено",
"noQuestions": "Питань поки що немає…\nАле вони вже в розробці.\nСлідкуйте за оновленнями - скоро зʼявиться новий контент!",
"metaTitle": "Q&A | DevLovers",
"metaDescription": "Питання та відповіді для підготовки до технічних співбесід",
"frontend": "Frontend",
Expand Down Expand Up @@ -1481,4 +1481,4 @@
}
}
}
}
}