From bf82ebfe1de3620a655d676e1c803e6597d3ae9b Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 18:19:38 +0100 Subject: [PATCH 1/4] feat(#81): improve discussion discoverability - Add comment count badge on the Discussion tab (seeded from SSR, updated live) - Add 'Diskussion starten' nudge above collapsed panel when no comments exist - Add empty state hint with speech-bubble icon inside the discussion panel - Fix CommentThread to fire onCountChange with SSR-seeded count on mount - Add tests for all three behaviours in CommentThread and DocumentBottomPanel Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 4 +- frontend/messages/en.json | 4 +- frontend/messages/es.json | 4 +- .../src/lib/components/CommentThread.svelte | 21 ++++++ .../components/CommentThread.svelte.spec.ts | 70 ++++++++++++++++++ .../lib/components/DocumentBottomPanel.svelte | 25 ++++++- .../DocumentBottomPanel.svelte.spec.ts | 74 +++++++++++++++++++ .../src/lib/components/PanelDiscussion.svelte | 5 +- 8 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/components/CommentThread.svelte.spec.ts create mode 100644 frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c3ca442c..e9d4d967 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -291,5 +291,7 @@ "enrich_skip": "Überspringen", "enrich_done_heading": "Alles erledigt!", "enrich_done_body": "Alle Dokumente wurden bearbeitet.", - "enrich_back_to_list": "Zurück zur Liste" + "enrich_back_to_list": "Zurück zur Liste", + "comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!", + "comment_start_discussion": "Diskussion starten →" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e8a86475..61e34dc9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -291,5 +291,7 @@ "enrich_skip": "Skip", "enrich_done_heading": "All done!", "enrich_done_body": "All documents have been processed.", - "enrich_back_to_list": "Back to list" + "enrich_back_to_list": "Back to list", + "comment_empty_hint": "No comments yet – start the discussion!", + "comment_start_discussion": "Start discussion →" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 55b7bba5..51d7ebe8 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -291,5 +291,7 @@ "enrich_skip": "Omitir", "enrich_done_heading": "¡Todo listo!", "enrich_done_body": "Todos los documentos han sido procesados.", - "enrich_back_to_list": "Volver a la lista" + "enrich_back_to_list": "Volver a la lista", + "comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!", + "comment_start_discussion": "Iniciar discusión →" } diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 12975503..212add7e 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -167,6 +167,9 @@ function cancelReply() { onMount(() => { if (loadOnMount) { reload(); + } else { + const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0); + onCountChange?.(total); } }); @@ -245,6 +248,24 @@ onMount(() => { {/snippet}
+ {#if comments.length === 0} +
+ + + +

{m.comment_empty_hint()}

+
+ {/if} {#each comments as thread, ti (thread.id)}
0 ? 'border-t border-line pt-4' : ''}> diff --git a/frontend/src/lib/components/CommentThread.svelte.spec.ts b/frontend/src/lib/components/CommentThread.svelte.spec.ts new file mode 100644 index 00000000..a444a7ce --- /dev/null +++ b/frontend/src/lib/components/CommentThread.svelte.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import CommentThread from './CommentThread.svelte'; +import type { Comment } from '$lib/types'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function makeComment(id: string, content = 'Hello'): Comment { + return { + id, + authorId: 'user-1', + authorName: 'Alice', + content, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + replies: [] + }; +} + +const baseProps = { + documentId: 'doc-1', + canComment: true, + currentUserId: 'user-1', + canAdmin: false +}; + +describe('CommentThread – empty state', () => { + it('shows empty state hint when there are no comments', async () => { + render(CommentThread, { ...baseProps, initialComments: [] }); + await expect + .element(page.getByText('Noch keine Kommentare – starte die Diskussion!')) + .toBeInTheDocument(); + }); + + it('does not show empty state hint when comments exist', async () => { + render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] }); + await expect + .element(page.getByText('Noch keine Kommentare – starte die Diskussion!')) + .not.toBeInTheDocument(); + }); +}); + +describe('CommentThread – onCountChange', () => { + it('calls onCountChange with initial SSR count on mount', async () => { + const onCountChange = vi.fn(); + render(CommentThread, { + ...baseProps, + initialComments: [makeComment('c-1'), makeComment('c-2')], + onCountChange + }); + expect(onCountChange).toHaveBeenCalledWith(2); + }); + + it('calls onCountChange with 0 when no initial comments', async () => { + const onCountChange = vi.fn(); + render(CommentThread, { ...baseProps, initialComments: [], onCountChange }); + expect(onCountChange).toHaveBeenCalledWith(0); + }); + + it('counts replies in the total', async () => { + const onCountChange = vi.fn(); + const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] }; + render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange }); + expect(onCountChange).toHaveBeenCalledWith(2); + }); +}); diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte index 94acb09d..7213d7c4 100644 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -98,10 +98,16 @@ const tabs: { id: DocumentPanelTab; label: () => string }[] = [ ]; const panelHeight = $derived(open ? height : MIN_HEIGHT); + +let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))()); + +function handleCountChange(count: number) { + discussionCount = count; +}
@@ -120,6 +126,15 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
+ {#if !open && discussionCount === 0} + + {/if} +
{#each tabs as tab (tab.id)} @@ -131,6 +146,13 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT); aria-pressed={activeTab === tab.id && open} > {tab.label()} + {#if tab.id === 'discussion' && discussionCount > 0} + {discussionCount} + {/if} {/each} @@ -165,6 +187,7 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT); canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + onCountChange={handleCountChange} /> {:else if activeTab === 'history'} diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts b/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts new file mode 100644 index 00000000..c36e8973 --- /dev/null +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import DocumentBottomPanel from './DocumentBottomPanel.svelte'; +import type { Comment } from '$lib/types'; + +afterEach(cleanup); + +function makeComment(id: string): Comment { + return { + id, + authorId: 'user-1', + authorName: 'Alice', + content: 'Hello', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + replies: [] + }; +} + +const doc = { id: 'doc-1', title: 'Test' }; + +const baseProps = { + doc, + canComment: true, + currentUserId: 'user-1', + canAdmin: false, + height: 300, + activeTab: 'discussion' as const +}; + +describe('DocumentBottomPanel – discussion badge', () => { + it('shows a badge with comment count on the Discussion tab when comments exist', async () => { + render(DocumentBottomPanel, { + ...baseProps, + comments: [makeComment('c-1'), makeComment('c-2')], + open: true + }); + await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument(); + await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2'); + }); + + it('does not show badge when there are no comments', async () => { + render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); + await expect.element(page.getByTestId('discussion-count-badge')).not.toBeInTheDocument(); + }); +}); + +describe('DocumentBottomPanel – start discussion nudge', () => { + it('shows nudge above drag handle when panel is closed and no comments', async () => { + render(DocumentBottomPanel, { ...baseProps, comments: [], open: false }); + await expect.element(page.getByTestId('discussion-nudge')).toBeInTheDocument(); + }); + + it('does not show nudge when panel is closed but comments exist', async () => { + render(DocumentBottomPanel, { + ...baseProps, + comments: [makeComment('c-1')], + open: false + }); + await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument(); + }); + + it('does not show nudge when panel is open', async () => { + render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); + await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument(); + }); + + it('opens the discussion tab when the nudge is clicked', async () => { + render(DocumentBottomPanel, { ...baseProps, comments: [], open: false }); + await userEvent.click(page.getByTestId('discussion-nudge')); + await expect.element(page.getByTestId('bottom-panel-content')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/PanelDiscussion.svelte b/frontend/src/lib/components/PanelDiscussion.svelte index bbed758a..291cf5c1 100644 --- a/frontend/src/lib/components/PanelDiscussion.svelte +++ b/frontend/src/lib/components/PanelDiscussion.svelte @@ -8,9 +8,11 @@ type Props = { canComment: boolean; currentUserId: string | null; canAdmin: boolean; + onCountChange?: (count: number) => void; }; -let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props(); +let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props = + $props();
@@ -20,5 +22,6 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + onCountChange={onCountChange} />
-- 2.49.1 From 20f6de4424449bc3162ef6b87f21ef48b3e30a4a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 18:43:48 +0100 Subject: [PATCH 2/4] refactor(#81): replace nudge button with always-visible count badge Show the discussion count badge on every state (including 0) instead of a separate nudge button. Simpler, less intrusive, and works without needing an extra element near the panel. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/DocumentBottomPanel.svelte | 13 +----- .../DocumentBottomPanel.svelte.spec.ts | 43 ++++--------------- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte index 7213d7c4..ee461e6c 100644 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -107,7 +107,7 @@ function handleCountChange(count: number) {
@@ -126,15 +126,6 @@ function handleCountChange(count: number) {
- {#if !open && discussionCount === 0} - - {/if} -
{#each tabs as tab (tab.id)} @@ -146,7 +137,7 @@ function handleCountChange(count: number) { aria-pressed={activeTab === tab.id && open} > {tab.label()} - {#if tab.id === 'discussion' && discussionCount > 0} + {#if tab.id === 'discussion'} { - it('shows a badge with comment count on the Discussion tab when comments exist', async () => { + it('always shows a badge on the Discussion tab', async () => { + render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); + await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument(); + await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0'); + }); + + it('shows the correct count when comments exist', async () => { render(DocumentBottomPanel, { ...baseProps, comments: [makeComment('c-1'), makeComment('c-2')], open: true }); - await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument(); await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2'); }); - - it('does not show badge when there are no comments', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); - await expect.element(page.getByTestId('discussion-count-badge')).not.toBeInTheDocument(); - }); -}); - -describe('DocumentBottomPanel – start discussion nudge', () => { - it('shows nudge above drag handle when panel is closed and no comments', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: false }); - await expect.element(page.getByTestId('discussion-nudge')).toBeInTheDocument(); - }); - - it('does not show nudge when panel is closed but comments exist', async () => { - render(DocumentBottomPanel, { - ...baseProps, - comments: [makeComment('c-1')], - open: false - }); - await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument(); - }); - - it('does not show nudge when panel is open', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: true }); - await expect.element(page.getByTestId('discussion-nudge')).not.toBeInTheDocument(); - }); - - it('opens the discussion tab when the nudge is clicked', async () => { - render(DocumentBottomPanel, { ...baseProps, comments: [], open: false }); - await userEvent.click(page.getByTestId('discussion-nudge')); - await expect.element(page.getByTestId('bottom-panel-content')).toBeInTheDocument(); - }); }); -- 2.49.1 From 0f0d74eb2fa3c3db5b5a1e4e1583851e14ebc626 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 19:41:10 +0100 Subject: [PATCH 3/4] fix(#81): use text-primary-fg for badge text so dark mode reads correctly In dark mode --c-primary flips to mint (#a1dcd8), making text-white unreadable. text-primary-fg is already paired correctly in both modes. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/DocumentBottomPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte index ee461e6c..a2aba5b8 100644 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -140,7 +140,7 @@ function handleCountChange(count: number) { {#if tab.id === 'discussion'} {discussionCount} {/if} -- 2.49.1 From 5d0a2a2c9c5e820ca0c3e71a16d61934e59833b7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 19:47:44 +0100 Subject: [PATCH 4/4] fix: use semantic color tokens for enrich hint box Replaced hardcoded brand-navy/brand-mint palette constants with semantic tokens (ink, accent, accent-bg) so the hint box themes correctly in dark mode. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 57f40f8e..7fff1e51 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -90,7 +90,7 @@ $effect(() => { {#if data.incompleteCount > 0}
{ class="h-6 w-6 opacity-60" />
-

+

{m.enrich_needs_metadata_title()}

-

+

{m.enrich_needs_metadata_count({ count: data.incompleteCount })}

{m.enrich_needs_metadata_cta()} → -- 2.49.1