Dashboard — Classic Split · Final Design Spec

Refocus the homepage on documents. The notification widget is removed from the dashboard — it already lives in the bell dropdown. The page is restructured into a two-column "Command Center": recent activity on the left, upload zone and missing-metadata queue on the right. Stats are demoted to a quiet footnote.

Final · Ready for implementation
Notification widget
On dashboard
→ Bell dropdown only
Layout
Single column stacked
→ 2-col split (desktop)
Upload button in action bar
Redundant button
→ Upload zone only
Stats
Prominent stat chips
→ Quiet footnote text
Backend changes
None — /api/stats already exists
📐 Mockup scale notice — all font-size, height, and padding values in the mockup CSS below are scaled to ~55% of actual implementation values. Do not copy sizes from mockup CSS. Use the ⚙ Implementation Reference tables after each section.
1 Desktop Layout — ≥ 1024 px
Full page 1440px
familienarchiv.local /
Documents Persons Correspondence
BC
Continue where you left off: E28 History Tee Dokument (bearbeitet)

Recent Activity

All documents →
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 Hash Tee — review30. März 2026
E28 Hash Tee — version30. März 2026
248 Documents  ·  34 Persons
Key decisions visible here
  • Notification widget removed entirely — bell badge in header is sufficient
  • Upload zone replaces the action-bar button — no redundancy
  • Stats footnote: quiet, not a chip — does not compete for attention
  • Right column is 300 px fixed — enough for upload + short metadata list
Annotations
① Search bar
Unchanged — existing SearchFilterBar component. Full width, no upload button appended.
② Resume strip
Unchanged — DashboardResumeStrip. Only renders when localStorage has a last-visited document. Hidden otherwise — no empty gap.
③ Dashboard grid
grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4
No items-start — the CSS Grid default align-items: stretch makes both columns the same height for free. The right column wrapper needs h-full so the flex container fills that height, and the metadata card gets flex-1 to consume the space left after the upload zone. Both columns are always flush at the bottom.
④ Recent Activity card
DashboardRecentDocuments receives a new optional stats prop. The footnote only renders when stats?.totalDocuments != null.
⑤ Upload zone
Existing DropZone component, no internal changes. Wrapped in {#if data.canWrite} — hidden for read-only users.
⑥ Needs Metadata card
Unchanged DashboardNeedsMetadata. Already renders nothing when incompleteDocs.length === 0. Amber top border signals "action required" without relying on color alone — the heading "Needs Metadata" and count pill are redundant cues.
⑦ Right column empty state
If both upload zone (no canWrite) and needs-metadata (no incomplete docs) are absent, the right grid cell is an empty <div>. The equal-height stretch still applies but an invisible column causes no visual artefact. When this case is detectable server-side, consider conditionally omitting the grid class so the left column runs full width — but this is a polish improvement, not a blocker.
Implementation Reference — Desktop Layout Real values · mockup above is ~55% scale · do not copy mockup CSS
ElementTailwind classesReal sizeNotes
Page wrapper mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8 py 32px, max-w 1280px Unchanged from current +page.svelte
Resume strip mb-4 (existing component, no change) mb 16px Unchanged
Dashboard grid wrapper mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px] gap 16px, right col 300px fixed No items-start. CSS Grid default is align-items: stretch — both columns are automatically the same height. Replaces the current conditional mentions+metadata grid.
Right column inner wrapper flex flex-col gap-4 h-full gap 16px, full grid cell height h-full is required so the flex container fills the stretched grid cell. Without it the column stops at content height and flex-1 on the child has nothing to fill.
DashboardNeedsMetadata wrapper flex-1 flex flex-col min-h-0 (wraps the component) grows to fill remaining height flex-1 consumes the space left after the DropZone. min-h-0 prevents flex overflow. The component's inner card should be h-full so the card border fills the space — content sits at the top, not stretched.
DropZone guard {#if data.canWrite} No changes to the DropZone component itself — wrapper condition only. When absent, the metadata card's flex-1 still fills the full right column height.
2 Mobile Layout — < 1024 px · stacking order
375 px iPhone
09:41
BC
5
Stacking order on mobile: recent docs → upload → metadata
Stacking order rationale
① Recent Activity — first
The most common task on mobile: browsing recently-touched documents. This should be immediately visible without scrolling past an upload zone most users won't use every visit.
② Upload zone — second
Mobile uploads happen but are less frequent than browsing. Positioned after the list so it doesn't block the primary use case, but still reachable with a single scroll.
③ Needs Metadata — last
Metadata enrichment on mobile is uncommon (small screen, lots of form fields). It appears last — accessible to those who need it, invisible noise to everyone else.
Touch targets: All interactive rows in DashboardRecentDocuments and DashboardNeedsMetadata must meet min-height 44px (WCAG 2.5.5). The upload zone's click target is the full box — no small button.
Dual-audience notes
Seniors 60+
Document title is Merriweather serif at 18px minimum — the most commonly undersized element in this type of list. Date label in text-ink-3 at 12px — acceptable for supplementary metadata but never below that. Sufficient line-height (1.6) and border separators provide clear row breaks without relying on color.
Millennials
Information density is preserved on desktop. The upload zone accepts drag-and-drop natively — no button required for the gesture-native user. Stats footnote satisfies curiosity without cluttering the primary view.
Implementation Reference — Mobile Stacking Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
Grid (mobile) grid-cols-1 (default, overridden at lg) full width No explicit mobile grid — single column is the default
Doc row touch target flex items-center justify-between py-3 border-b border-line min-h 44px via py-3 + content Most commonly undersized. py-3 (12px × 2) + 18px text = ~42px. Add min-h-[44px] to guarantee WCAG 2.5.5
Metadata row touch target flex items-center border-b border-line py-3 min-h 44px Same rule — min-h-[44px] required
Upload zone (mobile) Existing DropZone — no change to component full width, py-6 Entire zone is the click target — WCAG 2.5.5 satisfied by size
3 Recent Activity Card — stats footnote detail
Component detail

Recent Activity

All documents →
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 History Tee Dokument (bearbeitet)31. März 2026
E28 Hash Tee — review30. März 2026
E28 Hash Tee — version30. März 2026
248 Documents  ·  34 Persons
Stats footnote rules
  • Only renders when stats?.totalDocuments != null
  • Persons count follows only when stats?.totalPersons != null
  • The middle dot · is a text separator — not a visual-only cue
  • Uses text-ink-3 token — light enough to recede, but still WCAG AA (4.5:1) on white surface
  • No units abbreviation: "248 Documents", not "248 docs" — plain language for seniors
Component prop change
DashboardRecentDocuments.svelte — new prop
interface Props {
  recentDocs: Document[];
  stats?: {
    totalDocuments?: number;
    totalPersons?: number;
  } | null;
}
Footnote template snippet
{#if stats?.totalDocuments != null}
  <div class="mt-2 border-t border-line
              pt-3 font-sans text-xs
              text-ink-3">
    {stats.totalDocuments}
    {m.dashboard_stats_documents()}
    {#if stats.totalPersons != null}
       · 
      {stats.totalPersons}
      {m.dashboard_stats_persons()}
    {/if}
  </div>
{/if}
New i18n keys (all 3 locales)
Keydeenes
dashboard_stats_documentsDokumenteDocumentsDocumentos
dashboard_stats_personsPersonenPersonsPersonas
Implementation Reference — Recent Activity Card Real values · mockup above is ~55% scale
ElementTailwind classesReal sizeNotes
Card container rounded-sm border border-line bg-surface border 1px Unchanged from existing DashboardRecentDocuments styles
Card heading row flex items-center justify-between px-4 pt-4 pb-3 px 16px, pt 16px, pb 12px Unchanged
Section heading text font-sans text-xs font-bold tracking-widest text-gray-400 uppercase 12px / 700 Unchanged
Document title font-serif text-lg text-ink hover:text-ink-2 hover:underline 18px / 400 — most commonly undersized Must not fall below 18px. Serves both readability (seniors) and visual hierarchy
Date label ml-2 shrink-0 font-sans text-xs text-gray-400 12px Minimum permitted size for supplementary metadata — do not reduce further
Document row flex items-center justify-between border-b border-line py-2 px-4 last:border-0 py 8px, min-h ~44px with 18px text Add min-h-[44px] to guarantee WCAG 2.5.5 touch target
Stats footnote wrapper mt-2 border-t border-line px-4 pt-3 pb-4 font-sans text-xs text-ink-3 12px / 400, pt 12px, pb 16px New addition. text-ink-3 token must pass 4.5:1 on bg-surface — verify in both light and dark mode
4 Server Data Changes — +page.server.ts
Remove
  • The /api/notifications fetch from Promise.allSettled — the bell component fetches its own data client-side
  • The mentions: NotificationDTO[] variable and its allSettled result handling
  • The mentions key from the return object
  • The NotificationDTO type import (no longer used in this file)
Add
  • A /api/stats GET call inside the isDashboard allSettled block
  • stats: StatsDTO | null in the return — null on any failure
  • StatsDTO import from generated types (already in src/lib/generated/api.ts)
allSettled block — after change
const [incompleteResult,
       recentResult,
       statsResult] =
  await Promise.allSettled([
    api.GET('/api/documents/incomplete',
      { params: { query: { size: 5 } } }),
    api.GET('/api/documents/recent-activity',
      { params: { query: { size: 5 } } }),
    api.GET('/api/stats'),
  ]);

// … existing incomplete/recent handling …

let stats: StatsDTO | null = null;
if (statsResult.status === 'fulfilled'
    && statsResult.value.response.ok) {
  stats = statsResult.value.data ?? null;
}
5 Changes Summary

All files touched

Added / New behaviour

  • mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px] grid in +page.svelte
  • stats prop on DashboardRecentDocuments
  • Stats footnote inside DashboardRecentDocuments
  • /api/stats fetch in +page.server.ts
  • dashboard_stats_documents i18n key (de / en / es)
  • dashboard_stats_persons i18n key (de / en / es)

Removed

  • DashboardMentions import and usage in +page.svelte
  • /api/notifications fetch from server load
  • mentions variable and return value in server load
  • NotificationDTO import in +page.server.ts
  • The conditional 2-col grid for mentions+metadata

Kept unchanged

  • DashboardMentions.svelte file (not deleted)
  • DashboardNeedsMetadata.svelte (no changes)
  • DashboardResumeStrip.svelte (no changes)
  • DropZone.svelte (no changes)
  • NotificationBell.svelte — already has "View all" link
  • SearchFilterBar.svelte (no changes)

Explicitly out of scope

  • Dedicated /notifications overview page
  • DropZone accepted file types or upload behaviour
  • Dark mode token adjustments
  • Backend changes (none needed)
  • Any changes to admin, persons, or correspondence routes
6 Edge Cases
Read-only user (no canWrite)
DropZone is hidden. Right column contains only DashboardNeedsMetadata. If there are also no incomplete documents, the right <div class="flex flex-col gap-4"> is empty — the grid column produces no visual gap, recent activity expands naturally.
No incomplete documents
DashboardNeedsMetadata renders nothing (already guarded by incompleteDocs.length > 0). Combined with a canWrite user, the right column shows only the upload zone.
No recent documents (new / empty archive)
DashboardRecentDocuments already handles empty state (renders nothing when recentDocs.length === 0). Stats footnote still renders as long as the API call succeeded — "0 Documents · 0 Persons" is valid and informative.
/api/stats fetch fails
Promise.allSettled isolates the failure. stats is returned as null. The {#if stats?.totalDocuments != null} guard silently suppresses the footnote. Everything else renders normally — no error banner, no visual regression.
No last-visited document in localStorage
DashboardResumeStrip already handles this — it renders nothing. No gap between search bar and the dashboard grid.
Very long document title in recent activity
Title should be truncated with truncate Tailwind class (already present in existing component — verify). The date label has shrink-0 so it is never squeezed off-screen.
7 Acceptance Criteria
Dashboard page no longer renders the notifications/mentions widget. The bell icon in the header continues to work and its dropdown still shows the "View all notifications" link.
On viewports ≥ 1024 px the dashboard shows a two-column grid: recent activity left (~remaining width), sidebar right (300 px fixed). mobile
On viewports < 1024 px the columns stack: recent docs first, upload zone second, needs-metadata third. mobile
All interactive document rows have a minimum touch target height of 44 px. WCAG 2.5.5
Document titles in the recent-activity list render at minimum 18 px (Merriweather serif, text-lg). WCAG 1.4.4
Stats footnote "248 Documents · 34 Persons" appears at the bottom of the recent-activity card in text-xs text-ink-3. It is absent when the /api/stats call fails or returns null. data
Read-only users (no canWrite permission) do not see the upload zone. The dashboard still renders correctly without it.
When no incomplete documents exist, the Needs Metadata card is absent. The right column shows only the upload zone (or is empty for read-only users) — no visual gap or empty box.
npm run check passes — no TypeScript errors. The new stats prop on DashboardRecentDocuments is typed as StatsDTO | null | undefined.
npm run lint passes — no Prettier or ESLint errors.