# 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 /
Familienarchiv 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

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
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
Familienarchiv
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

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
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)

Key | de | en | es
--- | --- | --- | ---
dashboard_stats_documents | Dokumente | Documents | Documentos
dashboard_stats_persons | Personen | Persons | Personas

Implementation Reference — Recent Activity Card Real values · mockup above is ~55% scale

Element | Tailwind classes | Real size | Notes
--- | --- | --- | ---
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.