feat(dashboard): Classic Split — remove notification widget, restructure into 2-col layout #171
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
The dashboard homepage currently mixes two concerns: document management and conversation notifications. This makes the page feel unfocused — the title says "Documents" but roughly half the dashboard surface shows who replied to whom in conversations.
This issue implements the Classic Split (A1) design: a focused two-column layout with recent document activity on the left and upload zone + missing-metadata queue on the right. The notification widget is removed from the page — it is already accessible via the bell icon dropdown (which already has a "View all notifications" link at the bottom).
Design spec:
docs/specs/dashboard-classic-split-final-spec.htmlWhat changes
Removed
DashboardMentionsusage in+page.svelte(component file kept — may be reused on a future notifications page)/api/notificationsfetch from+page.server.ts(the bell fetches its own data client-side)mentionsvariable and return value in the server loadNotificationDTOimport in+page.server.tsAdded
grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4dashboard grid in+page.svelteitems-start— CSS Grid defaultalign-items: stretchmakes both columns the same height automaticallyflex flex-col gap-4 h-fullh-fullfills the stretched grid cell soflex-1on the child worksDashboardNeedsMetadatawrapped inflex-1 flex flex-col min-h-0— fills remaining height after upload zone/api/statsfetch in+page.server.ts(endpoint already exists, already in generated types)stats?: StatsDTO | nullprop onDashboardRecentDocuments— renders a quiet footnote: "248 Documents · 34 Persons"de.json/en.json/es.json:dashboard_stats_documents— "Dokumente" / "Documents" / "Documentos"dashboard_stats_persons— "Personen" / "Persons" / "Personas"Kept unchanged
DashboardMentions.svelte(not deleted)DashboardNeedsMetadata.svelte,DashboardResumeStrip.svelte,DropZone.svelteNotificationBell.svelte— already has "View all notifications" linkSearchFilterBar.svelteLayout
Mobile stacking order: recent docs → upload zone → metadata queue
Key implementation notes
items-starton the grid. CSS Gridstretchdefault +h-fullon the right column wrapper +flex-1on the metadata card wrapper achieves flush bottom edges with no JS.{#if stats?.totalDocuments != null}— silently absent if/api/statsfails.min-h-[44px](WCAG 2.5.5).text-lg(18 px) minimum — do not reduce.showRightColumnboolean (data.canWrite || incompleteDocs.length > 0) to conditionally applylg:grid-cols-[1fr_300px]and render the right column wrapper. This prevents an empty 300px ghost column for read-only users with a complete archive.Acceptance criteria
min-h-[44px]touch targettext-lg(18 px minimum)text-xs text-ink-3; absent when/api/statsfailsnpm run checkpasses (no TypeScript errors)npm run lintpasses👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
TDD coverage plan: The issue lists rich acceptance criteria but no corresponding test plan. Before implementing, I'd want to name the specific test files and behaviors upfront:
+page.server.tsload: should fetch/api/statsand include it in the return value+page.server.tsload: should returnstats: nullgracefully when/api/statsfails (the template guard{#if stats?.totalDocuments != null}implies this path must be tested)DashboardRecentDocuments.svelte: renders the stats footnote whenstats.totalDocumentsis providedDashboardRecentDocuments.svelte: omits the stats footnote whenstatsis null/undefinedDashboardMentions— dead code concern: The issue says "component file kept — may be reused on a future notifications page." From a clean code perspective, unused code is dead code. If there's no concrete issue tracking its future use, this is a smell. Consider either deleting it now and recovering it from git when needed, or opening a follow-up issue immediately so it doesn't become forgotten tech debt.{#each}key discipline: The issue description mentions document rows inDashboardRecentDocuments. Are those rows currently keyed with(doc.id)? Worth verifying no position-based iteration sneaks in here.Stats prop naming:
stats?: StatsDTO | null—StatsDTOis the generated OpenAPI type. The double-nullable (?+| null) is slightly redundant. Preferstats: StatsDTO | null = nullas a prop default, or juststats?: StatsDTOif the template guard coversundefined. Worth aligning with how other optional props are typed in the project.flex-1 flex flex-col min-h-0on the metadata card wrapper: themin-h-0trick is non-obvious (it overrides the defaultmin-height: autothat prevents flex children from shrinking). This is the one case where a brief comment is justified — not what it does, but why it's there.Suggestions
+page.svelte. These are the two behaviors most likely to regress silently.DashboardMentionsis genuinely needed later, create a separate Gitea issue now and link it in a commit message when deleting the file here. That keeps the codebase clean without losing intent.🏛️ Markus Keller — Application Architect
Questions & Observations
Parallel fetches in the load function: The issue adds a
/api/statsfetch alongside the existing document search fetch. If both areawaited sequentially in+page.server.ts, the page load time is the sum of both. SvelteKit'sloadsupportsPromise.all— are these fetched in parallel? Given both are read-only and independent, they should be:Worth calling out explicitly in the implementation notes.
Stats failure isolation: The template guard
{#if stats?.totalDocuments != null}correctly decouples the stats footnote from the page's critical path. But the load function still needs to ensure a/api/statsfailure doesn't propagate as an unhandled rejection that breaks the whole page render. The error should be caught and swallowed at the load level, returningstats: null. Is this handled?DashboardMentionskept but unused: At the module level this is fine — no coupling introduced. My only concern is future confusion: a developer openssrc/lib/components/and sees an apparently active component that isn't wired anywhere. A brief// not used on dashboard since #171 — candidate for notifications pagein the component file itself, or a linked follow-up issue, would preserve context cheaply.Layout correctness at the
lgbreakpoint edge: At exactly 1024px the grid snaps to two columns:1fr+300px+1remgap ≈ 700px left column. This is wide enough to be comfortable. Below 1023px it's single-column. This is a clean breakpoint choice — no concerns.h-fullon the right column wrapper: This works because CSS Gridstretchis the defaultalign-items. If anyone later addsitems-startto the grid container (a common reflex when columns look uneven),h-fullstops working. The spec calls this out but the code won't. A comment on the grid container reminding future editors not to additems-startwould prevent a subtle regression.Suggestions
+page.server.ts. This is the most impactful implementation note and worth making explicit in the issue.🧪 Sara Holt — QA Engineer
Questions & Observations
The AC list is well-structured and specific — it reads almost like a test plan already. Let me map it to the test pyramid.
Unit / Vitest tests needed (not implied by existing coverage):
+page.server.tstestresult.stats.totalDocumentsis populated+page.server.tstestresult.statsis null; page does not throwDashboardRecentDocumentstestgetByText(/248 Documents/)present whenstatsprovidedDashboardRecentDocumentsteststats = null+page.sveltetestE2E / Playwright tests (AC items that need browser verification):
WRITE_ALL, assert no DropZone is rendered.Edge cases I don't see covered in the AC:
DashboardNeedsMetadatahas a very long list? Doesflex-1 min-h-0correctly constrain the right column or does it overflow the viewport? Worth a manual check at minimum, ideally a Playwright screenshot at 1440px./api/statsreturnstotalDocuments: 0andtotalPersons: 0? Is the footnote shown as "0 Documents · 0 Persons"? Is0treated as truthy by the guard{#if stats?.totalDocuments != null}? (It is —0 != nullis true — but this is worth an explicit unit test case.)Suggestions
🔐 Nora "NullX" Steiner — Security Engineer
Questions & Observations
This is a frontend layout refactor with one new backend call. The attack surface change is minimal, but a few things are worth checking before the PR lands.
/api/stats— authorization levelThe issue states this endpoint "already exists, already in generated types." My question: what permission does the
StatsControllerendpoint require? If it'sREAD_ALL, all authenticated users can see the aggregate counts — that's appropriate. If it's unauthenticated or requires no permission at all, a logged-out user or a lower-privilege session could probe aggregate data (total documents and persons) without being granted read access. Worth confirming the@RequirePermissionannotation is present and set correctly on that endpoint.The data itself (document and person counts) is low-sensitivity, but the principle matters: every new API call the dashboard makes should be gated at the same permission level as the rest of the page.
Removal of
/api/notificationsfetchNo new risk here — removing a server-side fetch is strictly less attack surface. The bell fetches its own data client-side, which is already the existing behavior. No regression concern from a security perspective.
DashboardMentionskept but unusedNo direct security concern. The component is not rendered, so it introduces no new data paths or event listeners. If it were server-side rendered with its own fetch, I'd flag it — but since it's just a dormant file, it's clean.
stats?.totalDocuments != nullguardThis guard pattern is safe against null/undefined injection. No XSS or injection vector here — the values are numbers from a typed API response, rendered as text interpolation, not as
innerHTML.Suggestions
GET /api/statsrequiresREAD_ALL(or equivalent) by checking theStatsControllerannotation. If it's currently unprotected, that's an existing issue — but this PR is a good moment to catch it.🎨 Leonie Voss — UI/UX & Accessibility
Questions & Observations
The spec is thorough and the AC list explicitly calls out
min-h-[44px]andtext-lg— those are the two things I most commonly have to chase down after the fact, so it's great to see them in the criteria. A few things I'd still want to verify:text-ink-3— is this token defined?The stats footnote is spec'd as
text-xs text-ink-3. Istext-ink-3a Tailwind CSS 4 custom utility defined in the project'slayout.css? The CLAUDE.md brand utilities I see arebrand-navy,brand-mint, andbrand-sand. Iftext-ink-3is not defined, the footnote will render in the default text color (too dark, not the quiet secondary style intended). Double-check this token exists or substitute with an explicit color liketext-gray-400ortext-brand-navy/40.Right column when both children are absent
If a read-only user visits the dashboard (
canWrite = false) AND there are no incomplete documents, the right column renders with anh-full flex flex-col gap-4wrapper but no children. What does the user see? An empty 300px-wide column? This might look like a layout gap, especially on tablet where 300px is a significant fraction of the viewport. Consider: either hide the right column wrapper entirely with{#if canWrite || hasIncompleteDocuments}, or confirm thatDashboardNeedsMetadatais always present for non-empty archives.DropZone — visible cue beyond canWrite
The AC confirms "Read-only users do not see the upload zone." But what about authenticated users who have write permission but are on a mobile viewport where drag-and-drop isn't ergonomic? Does the DropZone have a tap-to-upload affordance with a visible button (not just a drop area)? This is particularly important for the senior audience who won't expect drag-and-drop.
Stats footnote contrast
text-xsis 12px — the absolute minimum per brand guidelines (never below 12px). At 12px, ensure the text color achieves at least 4.5:1 contrast ratio. Iftext-ink-3maps to something like#9ca3af(gray-400), that's approximately 2.6:1 on white — a WCAG AA failure. The footnote should use at minimumtext-gray-500(#6b7280, ~4.6:1 on white) or be bumped totext-sm(14px) where the 3:1 large-text threshold applies.Mobile stacking order confirmed
Recent docs → upload zone → metadata queue is correct thumb-reachable priority. The most important content (recent docs) at the top, the action (upload) next, the housekeeping task (metadata queue) last.
Suggestions
text-ink-3before implementation: either confirm the token or replace with a verified utility.🛠️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
This is a clean frontend-only refactor from an infrastructure perspective. No new services, no new environment variables, no schema migrations. Low-risk deployment.
Net effect on SSR performance
Removing the
/api/notificationsfetch from+page.server.tsis a win: one fewer outbound HTTP call per dashboard page render. This reduces SSR latency and removes a potential failure point on the critical rendering path. The replacement/api/statscall is lighter (aggregate counts, no list pagination). Net positive.One note: if these two fetches run sequentially rather than in parallel (see Markus's comment), the stats call could actually make page load slower than removing the notifications fetch. Worth verifying they're parallelised with
Promise.all.CI gates
The AC includes "npm run check passes" and "npm run lint passes." These should already be enforced as CI steps in the Gitea Actions workflow, not just manual pre-merge checklist items. If they're not currently blocking merge, that's a gap worth fixing separately — but this PR is a good reminder to check.
No new infrastructure concerns
DashboardMentionsfile being kept doesn't affect bundle size meaningfully (tree-shaking removes unused component code in SvelteKit builds)Deployment notes
This change is fully forwards-compatible. The SvelteKit app can be redeployed independently — no backend coordination required. A standard
npm run build+ container rebuild is sufficient.Suggestions
🎨 Leonie Voss — UI/UX & Accessibility — Interactive discussion summary
Worked through four open items from my initial review. All resolved.
✅
text-ink-3token existenceToken is defined in
layout.css:#6b7280(gray-500) in light mode — 4.8:1 on white, WCAG AA compliant. Dark mode value is#8b97a5at 7.1:1. No action needed.✅ Stats footnote contrast
Non-issue — resolved by confirming the token above. gray-500 clears the 4.5:1 threshold even at 12px.
✅ Ghost right column — AC added to issue
DashboardNeedsMetadataself-suppresses whenincompleteDocsis empty, and DropZone is already gated oncanWrite. But the right column wrapper div would still render empty for a read-only user with a complete archive — a visible 300px gap. Fixed by adding ashowRightColumnboolean (data.canWrite || incompleteDocs.length > 0) that gates both the grid class and the wrapper. Added to acceptance criteria.✅ DropZone touch affordance
Already handled. The full DropZone div is
role="button"withonclick={() => fileInput.click()}— tapping anywhere opens the native file picker. Keyboard-accessible too. No change needed.Overall this is a well-scoped, well-specified issue. The ghost column fix is the only structural gap — everything else was either already handled or a non-issue on inspection.
✅ Implementation complete — felix/issue-171-dashboard-classic-split
All 11 plan items implemented via red/green/refactor TDD. Final test suite: 172 server + 326 client = 498 tests, all green.
Commits
20923d0feat(dashboard): replace notifications fetch with stats in server loadf618364feat(dashboard): add stats footnote and min-h touch target to DashboardRecentDocuments92e7aa1feat(dashboard): Classic Split — 2-col layout, remove DashboardMentions widgetWhat was implemented
+page.server.ts/api/notificationsfetch; replaced with/api/statsin thePromise.allSettleddashboard blockstats: StatsDTO | null(null on network failure) instead ofmentionsNotificationDTOimport removedDashboardRecentDocuments.sveltestats?: StatsDTO | nullprop (default null)data-testid="dashboard-stats-footnote"rendered with{#if stats?.totalDocuments != null}— shown for 0, absent when nullmin-h-[44px]added to every doc row (data-testid="doc-row-{id}") — WCAG 2.5.5data-testid="doc-date-{id}"preserved on date spans+page.svelteDashboardMentionsremoved from import and template (file kept)grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px](conditional onshowRightColumn)showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0)— no ghost column for read-only users with complete archivedata-testid="dashboard-right-column",flex h-full flex-col gap-4DropZonemoved into right column, still gated ondata.canWriteDashboardNeedsMetadatawrapped inflex min-h-0 flex-1 flex-col— fills remaining heighti18n
dashboard_stats_documents/dashboard_stats_personsadded tode.json,en.json,es.jsonSecurity note (out of scope)
StatsControllerhas no@RequirePermissionannotation as NullX flagged. The data (aggregate counts) is low-sensitivity and the endpoint is behind Spring Security's auth gate, but the annotation is missing. Recommend a follow-up issue.Acceptance criteria status
items-start)min-h-[44px]touch targettext-lg(18 px minimum)text-xs text-ink-3; absent when/api/statsfailsnpm run checkpasses (no new type errors in source files)npm run lintpasses