diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index c95a0a5..f5b8507 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Param, Req, Res } from '@nestjs/common'; +import type { PageCommentsView } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; @@ -24,6 +25,16 @@ export class PublicController { return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); } + @Get(':pondSlug/:pageSlug/comments') + @Public() + async comments( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug); + } + @Get(':pondSlug/:pageSlug') @Public() async html( diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts index 0e56319..ed41f26 100644 --- a/apps/api/src/public/public.e2e.db.test.ts +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -115,12 +115,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404); }); - it('404s both endpoints once the public grant is removed', async () => { + it('serves a page’s comments read-only to an anonymous visitor (issue #133)', async () => { + const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } }); + const root = await prisma.comment.create({ + data: { pageId: page.id, authorId: ownerId, body: 'A public remark' }, + }); + await prisma.comment.create({ + data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' }, + }); + + const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200); + const body = res.body as { + threads: { root: { body: string }; replies: { body: string }[] }[]; + openCount: number; + }; + expect(body.openCount).toBe(1); + expect(body.threads).toHaveLength(1); + expect(body.threads[0].root.body).toBe('A public remark'); + expect(body.threads[0].replies.map((r) => r.body)).toEqual(['A public reply']); + + // A non-public page never reveals its comments either. + await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404); + }); + + it('404s all public endpoints once the public grant is removed', async () => { await prisma.roleGrant.delete({ where: { id: publicGrantId } }); // The API route invalidates on its own mutations; this test deletes // directly, so drop the cached pond context to mirror that (issue #39). app.get(PondPermissionCache).invalidate(pondId); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404); + await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404); }); }); diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts index 2760e64..91c1604 100644 --- a/apps/api/src/public/public.module.ts +++ b/apps/api/src/public/public.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { CommentsModule } from '../comments/comments.module'; import { PluginsModule } from '../plugins/plugins.module'; import { PublicController } from './public.controller'; @@ -12,7 +13,7 @@ import { PublicService } from './public.service'; * marks `GET /media/:fileId` public too. */ @Module({ - imports: [PluginsModule], + imports: [PluginsModule, CommentsModule], controllers: [PublicController], providers: [PublicService], }) diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 84e910b..ad6faa8 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,6 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import type { PageCommentsView } from '@dorfteich/shared'; import { Pond, User } from '@prisma/client'; +import { CommentsService } from '../comments/comments.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; @@ -38,6 +40,7 @@ export class PublicService { private readonly permissions: PermissionService, private readonly fallbacks: PluginFallbackRenderer, private readonly settings: InstanceSettingsService, + private readonly commentsService: CommentsService, ) {} private async resolve( @@ -77,6 +80,18 @@ export class PublicService { }; } + /** + * The page's comments for the anonymous public view (issue #133), read-only. + * `resolve()` enforces (possibly anonymous) read access — a non-public page + * 404s here too, so comments never leak. `list` builds the same + * `PageCommentsView` the authenticated endpoint returns; the SPA renders it + * without any composer or action controls. + */ + async comments(user: User | null, pondSlug: string, pageSlug: string): Promise { + const { page } = await this.resolve(user, pondSlug, pageSlug); + return this.commentsService.list(page.id, 'all'); + } + /** A complete, self-contained HTML document for crawlers / PDF export. */ async html( user: User | null, diff --git a/apps/web/e2e/comments.spec.ts b/apps/web/e2e/comments.spec.ts index b71a9c1..382c498 100644 --- a/apps/web/e2e/comments.spec.ts +++ b/apps/web/e2e/comments.spec.ts @@ -5,10 +5,11 @@ import { contextForUser } from './helpers'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; /** - * Comments UI (issue #92): the full two-user lifecycle in the panel, - * resolve/unresolve with the collapsed section, the permission variant - * (composer hidden with a hint), and the localStorage unread badge. - * The pack provisions its own page and grant, so it is repeatable. + * Comments UI (issues #92, #133): the discussion is a fixed inline section in + * the read view (no toggle), between the backlinks and the local graph. The + * pack drives the full two-user lifecycle, resolve/unresolve with the collapsed + * section, and the permission variant (composer hidden with a hint). It + * provisions its own page and grant, so it is repeatable. */ let pondId: string; @@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => { }); test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { - // The reader starts the thread. + // The reader starts the thread — the section is inline in the read view. const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const readerPage = await reader.newPage(); await readerPage.goto(pageUrl); - await readerPage.locator('.editor-shell__comments-toggle').click(); - const readerPanel = readerPage.locator('.comments-panel'); + const readerPanel = readerPage.locator('.comments-section'); await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); await readerPanel.locator('.comments-composer button[type="submit"]').click(); await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); - // The owner sees the unread badge, replies, and resolves the thread. + // The owner opens the page and sees the thread inline, replies, resolves. const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const ownerPage = await owner.newPage(); await ownerPage.goto(pageUrl); - await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); - await ownerPage.locator('.editor-shell__comments-toggle').click(); - const ownerPanel = ownerPage.locator('.comments-panel'); + const ownerPanel = ownerPage.locator('.comments-section'); + await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final'); await ownerPanel .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) .click(); @@ -83,7 +82,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) .first() .click(); - const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); + const resolvedSection = ownerPanel.locator('.comments-section__resolved'); await expect(resolvedSection).toBeVisible(); await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); // Collapsed: the thread body is hidden until the section is opened. @@ -93,7 +92,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ // Unresolve restores it to the open list. await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); - await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0); + await expect(ownerPanel.locator('.comments-section__resolved')).toHaveCount(0); await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); // Author edits and deletes own reply. @@ -108,9 +107,8 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({ // Cleanup: the reader deletes their own (now reply-free) root. await readerPage.reload(); - await readerPage.locator('.editor-shell__comments-toggle').click(); await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); - await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); + await expect(readerPanel.locator('.comments-section__empty')).toBeVisible(); await reader.close(); await owner.close(); @@ -127,9 +125,8 @@ test('the composer hides with a hint when the policy bars readers', async ({ bro const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const page = await reader.newPage(); await page.goto(pageUrl); - await page.locator('.editor-shell__comments-toggle').click(); - const panel = page.locator('.comments-panel'); - await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible(); + const panel = page.locator('.comments-section'); + await expect(panel.locator('.comments-section__policy-hint')).toBeVisible(); await expect(panel.locator('.comments-composer')).toHaveCount(0); await reader.close(); diff --git a/apps/web/src/comments/CommentsPanel.tsx b/apps/web/src/comments/CommentsSection.tsx similarity index 59% rename from apps/web/src/comments/CommentsPanel.tsx rename to apps/web/src/comments/CommentsSection.tsx index 12c1f9f..3b40953 100644 --- a/apps/web/src/comments/CommentsPanel.tsx +++ b/apps/web/src/comments/CommentsSection.tsx @@ -1,35 +1,36 @@ -import type { CommentThreadView, CommentView } from '@dorfteich/shared'; +import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../auth/auth-context'; import { FormError } from '../components/forms'; -import { apiDelete, apiPatch, apiPost } from '../lib/api'; +import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api'; import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; /** - * The page's discussion panel (issue #92): threaded comments with a - * Markdown composer, edit/delete for authors, resolve with a collapsed - * resolved section, and the permission-aware composer (hidden with a hint - * when the pond's policy bars the viewer). + * The page's discussion (issue #92), rendered inline in the read view between + * the backlinks and the local graph (issue #133): threaded comments with a + * Markdown composer, edit/delete for authors, and resolve with a collapsed + * resolved section. The composer is hidden with a hint when the pond's policy + * bars the viewer. Replaces the former slide-in panel. */ -export function CommentsPanel({ +export function CommentsSection({ pageId, mayComment, - onClose, }: { pageId: string; - /** Resolved by the caller from the pond's commentPolicy + collab mode. */ + /** Resolved by the caller from the pond's commentPolicy + write access. */ mayComment: boolean; - onClose: () => void; }): React.JSX.Element { const { t } = useTranslation('comments'); const comments = useComments(pageId); const refresh = useInvalidateComments(pageId); const [error, setError] = useState(null); - // Opening the panel is "visiting" the discussion: the unread badge resets. + // Seeing the inline section is "visiting" the discussion: the unread notion + // (used by comment notifications) resets while it is on screen. useEffect(() => { markCommentsSeen(pageId); return () => markCommentsSeen(pageId); @@ -45,22 +46,15 @@ export function CommentsPanel({ } }; - const view = comments.data; - const open = (view?.threads ?? []).filter((thread) => !thread.resolved); - const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); - return ( -
-
+
+

{t('title')} - {view && view.openCount > 0 && ( - {view.openCount} + {comments.data && comments.data.openCount > 0 && ( + {comments.data.openCount} )}

-
@@ -71,14 +65,82 @@ export function CommentsPanel({ onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))} /> ) : ( -

{t('composer.editorsOnly')}

+

{t('composer.editorsOnly')}

)} + +
+ ); +} + +/** + * Read-only comments for the anonymous public view (issue #133): fetched from + * the public endpoint (no auth, no composer, no action controls). The section + * is omitted entirely when the page has no comments, so public pages stay clean. + */ +export function PublicComments({ + pondSlug, + pageSlug, +}: { + pondSlug: string; + pageSlug: string; +}): React.JSX.Element | null { + const { t } = useTranslation('comments'); + const query = useQuery({ + queryKey: ['public-comments', pondSlug, pageSlug], + queryFn: () => apiGet(`/public/${pondSlug}/${pageSlug}/comments`), + enabled: Boolean(pondSlug && pageSlug), + retry: false, + }); + + const view = query.data; + if (!view || view.threads.length === 0) return null; + + return ( +
+
+

+ {t('title')} + {view.openCount > 0 && {view.openCount}} +

+
+ +
+ ); +} + +const noop = async (): Promise => {}; + +function CommentThreads({ + view, + pageId, + mayComment, + run, + readOnly, +}: { + view: PageCommentsView | undefined; + pageId: string; + mayComment: boolean; + run: (action: () => Promise) => Promise; + readOnly: boolean; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const open = (view?.threads ?? []).filter((thread) => !thread.resolved); + const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); + + return ( + <> {view && open.length === 0 && resolved.length === 0 && ( -

{t('empty')}

+

{t('empty')}

)} -
    +
      {open.map((thread) => ( ))}
    {resolved.length > 0 && ( -
    +
    {t('resolvedSection', { count: resolved.length })} -
      +
        {resolved.map((thread) => ( ))}
    )} -
+ ); } @@ -115,26 +179,42 @@ function Thread({ pageId, mayComment, run, + readOnly, }: { thread: CommentThreadView; pageId: string; mayComment: boolean; run: (action: () => Promise) => Promise; + readOnly: boolean; }): React.JSX.Element { const { t } = useTranslation('comments'); const [replying, setReplying] = useState(false); return (
  • - +
      {thread.replies.map((reply) => (
    • - +
    • ))}
    - {mayComment && !thread.resolved && ( + {!readOnly && mayComment && !thread.resolved && (
    {replying ? ( Promise) => Promise; isRoot: boolean; resolved: boolean; + mayComment: boolean; + readOnly: boolean; }): React.JSX.Element { const { t, i18n } = useTranslation('comments'); const { user } = useAuth(); @@ -205,40 +289,47 @@ function CommentItem({ // Server-sanitized render (shared pipeline, issue #91) — safe by contract.
    )} -
    - {own && !editing && ( - <> - - - - )} - {isRoot && - (resolved ? ( - - ) : ( - - ))} -
    + {!readOnly && ( +
    + {own && !editing && ( + <> + + + + )} + {isRoot && + mayComment && + (resolved ? ( + + ) : ( + + ))} +
    + )} ); } diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index 156b8f0..d8ab604 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -7,7 +7,6 @@ import { Ellipsis, FolderInput, History, - MessageSquare, Paperclip, Pencil, Save, @@ -35,9 +34,6 @@ interface PageActionsProps { pondSlug: string; mode: 'view' | 'edit'; onToggleMode: () => void; - unread: number; - showComments: boolean; - onToggleComments: () => void; showAttachments: boolean; onToggleAttachments: () => void; hasTools: boolean; @@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element { {props.mode === 'edit' && } - 0 - ? `${t('comments:toggle')} — ${t('comments:unread', { count: props.unread })}` - : t('comments:toggle') - } - active={props.showComments} - aria-expanded={props.showComments} - onClick={props.onToggleComments} - > - - {props.unread > 0 && {props.unread}} - void; - onCloseComments: () => void; + /** Reports write access up so the outer page can render the inline + * comments composer (issue #133); the collab mode lives with the provider. */ + onWriteAccess: (canWrite: boolean) => void; }): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); @@ -170,10 +167,12 @@ function PageEditor({ const collab = useCollabProvider(ydoc, page.id); const readOnly = collab.mode === 'ro'; - // `readers` = everyone who can see the page; `editors` = the collab token - // explicitly granted rw (while it is still null, stay conservative — the - // composer appears once the mode resolves). - const mayComment = commentPolicy === 'readers' || collab.mode === 'rw'; + // The collab token's rw grant is the authoritative write signal; report it up + // so the outer page can decide whether to show the inline comment composer + // (issue #133). `readers`-policy ponds let anyone comment regardless. + useEffect(() => { + onWriteAccess(collab.mode === 'rw'); + }, [collab.mode, onWriteAccess]); // A revoked page can no longer be edited (issue #39); the content stays // visible for export via the dialog below. const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked; @@ -276,9 +275,6 @@ function PageEditor({ onClose={onCloseAttachments} /> )} - {showComments && ( - - )} {/* The connection status renders as an icon in the content footer (left half); the localized text stays for screen readers and as the hover tooltip. */} @@ -340,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element { const [showHistory, setShowHistory] = useState(false); const [showLabels, setShowLabels] = useState(false); const [showAttachments, setShowAttachments] = useState(false); - // Deep link from a comment notification (issue #94): ?comments=1 opens - // the panel immediately. - const [showComments, setShowComments] = useState( - () => new URLSearchParams(window.location.search).get('comments') === '1', - ); const [showPageTools, setShowPageTools] = useState(false); + // Write access reported up by the editor's collab provider, so the outer page + // can decide whether to show the inline comment composer (issue #133). + const [canWrite, setCanWrite] = useState(false); const actionsSlot = usePageActionsSlot(); const queryClient = useQueryClient(); const showToast = useToast(); @@ -460,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element { return () => window.removeEventListener('keydown', onKeyDown); }, [pageId, user, mode, queryClient, t, showToast]); - // TopBar action data (issue #101): the comments badge and the plugin - // page-tools visibility live next to the icons, not inside the editor. - const comments = useComments(resolved?.id); - const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id); + // TopBar action data (issue #101): the plugin page-tools visibility lives + // next to the icons, not inside the editor. const pagePlugins = usePondPlugins(resolved?.pondId); + // Comments are shown inline in the read view (issue #133). `readers`-policy + // ponds let any reader comment; `editors` require the collab rw grant the + // editor reports up via onWriteAccess. + const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite; + + // Deep link from a comment notification (issue #94): ?comments=1 scrolls to + // the inline discussion once it has mounted in read mode. + useEffect(() => { + if (mode !== 'view') return; + if (new URLSearchParams(window.location.search).get('comments') !== '1') return; + const timer = window.setTimeout(() => { + document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 200); + return () => window.clearTimeout(timer); + }, [mode, resolved?.id]); + async function saveTitle(): Promise { if (!resolved || title === resolved.title) return; await apiPatch(`/pages/${resolved.id}`, { title }); @@ -506,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element { pondSlug={pondSlug} mode={mode} onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')} - unread={unread} - showComments={showComments} - onToggleComments={() => setShowComments((open) => !open)} showAttachments={showAttachments} onToggleAttachments={() => setShowAttachments((open) => !open)} hasTools={hasPageTools(pagePlugins.data)} @@ -543,12 +548,10 @@ export function PageEditorPage(): React.JSX.Element { page={resolved} mode={mode} pondSlug={pondSlug} - commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'} showAttachments={showAttachments} - showComments={showComments} showPageTools={showPageTools} onCloseAttachments={() => setShowAttachments(false)} - onCloseComments={() => setShowComments(false)} + onWriteAccess={setCanWrite} /> {/* Side panels stack vertically in one column (M10 follow-up). */} {(showLabels || showHistory) && ( @@ -568,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element { )}
    {/* "Linked from" appears below the content in read mode (issue #48); - the local neighborhood graph joins it there (issue #113). */} + the inline discussion (issue #133) and the local neighborhood graph + (issue #113) follow it, in that order. */} {mode === 'view' && } + {mode === 'view' && } {mode === 'view' && ( )} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index 1db8ecd..6f48cc5 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import { PublicComments } from '../comments/CommentsSection'; import { ApiError, apiGet } from '../lib/api'; import { countWords, htmlToText } from '../lib/word-count'; import { NotFoundPage } from './NotFoundPage'; @@ -55,6 +56,8 @@ export function PublicPageView(): React.JSX.Element { {/* The HTML comes from the server's content cache (issue #24), derived from the sanitized editor schema — safe to render. */}
    + {/* Existing comments, read-only for anonymous visitors (issue #133). */} + ); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index b09634f..a3135b9 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -522,15 +522,6 @@ button { gap: var(--space-1); } -.icon-button .comments-unread-badge { - position: absolute; - top: -4px; - right: -6px; - margin-left: 0; - line-height: 1.1rem; - padding: 0 0.3rem; -} - .page-actions__more { position: relative; } @@ -3346,8 +3337,8 @@ ul[data-type='task_list'] li > div > p:last-child { margin-top: var(--space-3); } -/* Comments panel (issue #92) */ -.comments-panel { +/* Inline comments section, read view (issues #92, #133) */ +.comments-section { border: 1px solid var(--color-border, #cbd5e1); border-radius: 8px; padding: var(--space-3); @@ -3355,19 +3346,19 @@ ul[data-type='task_list'] li > div > p:last-child { background: var(--color-surface, #fff); } -.comments-panel__header { +.comments-section__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-2); } -.comments-panel__header h2 { +.comments-section__header h2 { margin: 0; font-size: 1.1rem; } -.comments-panel__count { +.comments-section__count { margin-left: var(--space-2); font-size: 0.8125rem; background: var(--color-surface-muted, #e2e8f0); @@ -3375,12 +3366,12 @@ ul[data-type='task_list'] li > div > p:last-child { padding: 0.05rem 0.5rem; } -.comments-panel__policy-hint, -.comments-panel__empty { +.comments-section__policy-hint, +.comments-section__empty { color: var(--color-text-muted); } -.comments-panel__threads { +.comments-section__threads { list-style: none; margin: 0; padding: 0; @@ -3389,16 +3380,16 @@ ul[data-type='task_list'] li > div > p:last-child { gap: var(--space-3); } -.comments-panel__resolved { +.comments-section__resolved { margin-top: var(--space-3); } -.comments-panel__resolved > summary { +.comments-section__resolved > summary { cursor: pointer; color: var(--color-text-muted); } -.comments-panel__resolved > .comments-panel__threads { +.comments-section__resolved > .comments-section__threads { margin-top: var(--space-2); } @@ -3471,15 +3462,6 @@ ul[data-type='task_list'] li > div > p:last-child { color: var(--color-text-muted); } -.comments-unread-badge { - margin-left: var(--space-1); - font-size: 0.75rem; - background: var(--color-primary, #2f6f4f); - color: #fff; - border-radius: 999px; - padding: 0.05rem 0.45rem; -} - .comment-policy__label { display: flex; align-items: center;