[Mappe·Shared] EmptyState — dashed, serif heading, German ellipsis (§7) #860

Open
opened 2026-06-16 10:53:57 +02:00 by marcel · 0 comments
Owner

Shared component · Story 4. Part of #853.

Context

ChronikEmptyState and others are icon + sans-bold heading with a solid border — off-spec. Several pages roll their own. DESIGN_RULES §7 wants a dashed, quiet, Sie-form empty state.

Scope

Create $lib/shared/primitives/EmptyState.svelte — dashed 1px var(--c-line) border, centered, serif heading + Montserrat subline ending in the German ellipsis . Props: heading, subline, optional action slot.

Migration targets — all five are in scope for this issue:

Target File Notes
Chronik $lib/activity/ChronikEmptyState.svelte Has 3 variants (first-run/filter-empty/inbox-zero) + icon — variant→{heading,subline} mapping and icon move INTO the caller (aktivitaeten); the shared primitive carries neither. Delete ChronikEmptyState after migration; update or remove its two test files (*.spec.ts + *.test.ts).
documents list route-level ad-hoc empty state Needs new Paraglide keys.
themen route-level ad-hoc empty state Needs new Paraglide keys.
stammbaum route-level ad-hoc empty state Needs new Paraglide keys.
persons/review route-level ad-hoc empty state Needs new Paraglide keys.

Migration scope — decided: TranscribeCoachEmptyState ($lib/shared/help/) is out of scope — it is a help-coach variant with its own layout intent, not a §7-quiet empty state; migrating it would leave a second competing empty-state API. It stays untouched. PersonsEmptyState (route-level) maps to the persons/review target above.

ChronikEmptyState fate — decided: delete the component after migration (all three callers move to EmptyState directly); do not leave a dead thin wrapper. Update or remove both test files.

EmptyState stays leaf-level: the shared primitive must not import back up into any domain package ($lib/activity, $lib/person, etc.). Variant/string/icon logic stays in each caller.

API

<!-- Props -->
heading: string          <!-- rendered via {heading} — default escaping, never {@html} -->
subline: string          <!-- rendered via {subline} — default escaping, never {@html} -->
<!-- Slots -->
<slot name="action" />   <!-- optional CTA link/button; caller must not pass {@html} content -->

Security note (implementer): heading, subline, and the action slot must render through Svelte's default {…} escaping — never {@html}. Future callers that interpolate person names, tag labels, or document titles rely on this default for XSS prevention (CWE-79).

Visual spec (DESIGN_RULES §7 / AUTHORING_KIT §10)

Property Value
Border 1px dashed var(--c-line)
Border radius rounded-sm (never rounded-lg)
Padding py-12 px-6 (48 px / 24 px)
Heading font-serif, 20 px, default ink
Subline font-sans (Montserrat), 13 px, text-ink-3
Subline ending German ellipsis
Icon None (§7: no icon)
Variants None baked in — caller supplies heading/subline strings
Max-width on subline max-w-prose so it does not run edge-to-edge at 320 px

Heading semantics — decided: render heading as <p class="font-serif …"> (matching existing ChronikEmptyState); wrap the whole block in role="status" so screen readers announce the empty state. This avoids disrupting the page's heading hierarchy.

action slot: any link/button inside must carry a visible focus-visible ring (inherit from project focus styles). The slot itself imposes no markup — callers wrap in <a> or <button> as appropriate.

i18n

All visible strings (heading, subline) must be Paraglide message keys. Callers pass the result of m.key(), not raw string literals.

For each of the four newly migrated ad-hoc pages (documents list, themen, stammbaum, persons/review) add empty-state keys in messages/de.json (authored), en.json, and es.json (stubs acceptable for en/es on first ship). Key naming convention: <page>_empty_heading / <page>_empty_subline.

The Chronik keys already exist inside ChronikEmptyState — keep them, move them to the caller (aktivitaeten).

Tests

Create $lib/shared/primitives/EmptyState.svelte.spec.ts (vitest-browser, copy pattern from ChronikEmptyState.svelte.spec.ts). Assert:

  • dashed border class present
  • rounded-sm present, rounded-lg absent
  • heading renders with font-serif class
  • subline ends with
  • action slot renders when provided
  • heading/subline are NOT wrapped in {@html} (static grep check — confirm no {@html} in EmptyState.svelte)

Acceptance

  • Matches DESIGN_RULES §7: 1px dashed var(--c-line) border, rounded-sm, serif heading (20 px), Montserrat subline (13 px text-ink-3) ending , no icon
  • Padding py-12 px-6; subline capped at max-w-prose — does not run edge-to-edge at 320 px
  • All five migration targets migrated; ChronikEmptyState deleted; its test files updated or removed
  • Each migrated page has Paraglide keys in de/en/es; no hardcoded string literals
  • EmptyState.svelte.spec.ts passes (dashed border, font-serif, rounded-sm, , action slot, no {@html})
  • action slot: focus-visible ring visible; keyboard-reachable
  • Visual diff at 320 / 768 / 1440 px in light AND dark on: Chronik, documents list, themen, stammbaum, persons/review — no regression
  • axe-core passes both themes; ≥ 4.5:1 contrast on heading and subline in light and dark; focus-visible preserved

Out of Scope

  • Loading/skeleton states — not part of this primitive
  • Filter-empty vs first-run vs inbox-zero variant logic — stays in each caller
  • TranscribeCoachEmptyState — out of scope (help-coach variant, not a §7 empty state)
  • Any backend change, Flyway migration, new env var, or CI workflow change
  • Rewiring consumers beyond the five named migration targets

Depends on: none. Refs: DESIGN_RULES §7, _AUTHORING_KIT.md §10, $lib/shared/primitives/BackButton.svelte (pattern).

**Shared component · Story 4.** Part of #853. ## Context `ChronikEmptyState` and others are icon + sans-bold heading with a solid border — off-spec. Several pages roll their own. `DESIGN_RULES §7` wants a dashed, quiet, Sie-form empty state. ## Scope Create `$lib/shared/primitives/EmptyState.svelte` — dashed `1px var(--c-line)` border, centered, **serif heading** + Montserrat subline ending in the German ellipsis `…`. Props: `heading`, `subline`, optional `action` slot. **Migration targets — all five are in scope for this issue:** | Target | File | Notes | |---|---|---| | Chronik | `$lib/activity/ChronikEmptyState.svelte` | Has 3 variants (first-run/filter-empty/inbox-zero) + icon — variant→{heading,subline} mapping and icon move INTO the caller (`aktivitaeten`); the shared primitive carries neither. Delete `ChronikEmptyState` after migration; update or remove its two test files (`*.spec.ts` + `*.test.ts`). | | documents list | route-level ad-hoc empty state | Needs new Paraglide keys. | | themen | route-level ad-hoc empty state | Needs new Paraglide keys. | | stammbaum | route-level ad-hoc empty state | Needs new Paraglide keys. | | persons/review | route-level ad-hoc empty state | Needs new Paraglide keys. | **Migration scope — decided:** `TranscribeCoachEmptyState` (`$lib/shared/help/`) is **out of scope** — it is a help-coach variant with its own layout intent, not a §7-quiet empty state; migrating it would leave a second competing empty-state API. It stays untouched. `PersonsEmptyState` (route-level) maps to the persons/review target above. **ChronikEmptyState fate — decided:** delete the component after migration (all three callers move to `EmptyState` directly); do not leave a dead thin wrapper. Update or remove both test files. **`EmptyState` stays leaf-level:** the shared primitive must not import back up into any domain package (`$lib/activity`, `$lib/person`, etc.). Variant/string/icon logic stays in each caller. ## API ```svelte <!-- Props --> heading: string <!-- rendered via {heading} — default escaping, never {@html} --> subline: string <!-- rendered via {subline} — default escaping, never {@html} --> <!-- Slots --> <slot name="action" /> <!-- optional CTA link/button; caller must not pass {@html} content --> ``` **Security note (implementer):** `heading`, `subline`, and the `action` slot must render through Svelte's default `{…}` escaping — never `{@html}`. Future callers that interpolate person names, tag labels, or document titles rely on this default for XSS prevention (CWE-79). ## Visual spec (DESIGN_RULES §7 / AUTHORING_KIT §10) | Property | Value | |---|---| | Border | `1px dashed var(--c-line)` | | Border radius | `rounded-sm` (never `rounded-lg`) | | Padding | `py-12 px-6` (48 px / 24 px) | | Heading | `font-serif`, 20 px, default ink | | Subline | `font-sans` (Montserrat), 13 px, `text-ink-3` | | Subline ending | German ellipsis `…` | | Icon | None (§7: no icon) | | Variants | None baked in — caller supplies heading/subline strings | | Max-width on subline | `max-w-prose` so it does not run edge-to-edge at 320 px | **Heading semantics — decided:** render heading as `<p class="font-serif …">` (matching existing `ChronikEmptyState`); wrap the whole block in `role="status"` so screen readers announce the empty state. This avoids disrupting the page's heading hierarchy. **`action` slot:** any link/button inside must carry a visible `focus-visible` ring (inherit from project focus styles). The slot itself imposes no markup — callers wrap in `<a>` or `<button>` as appropriate. ## i18n All visible strings (`heading`, `subline`) must be Paraglide message keys. Callers pass the result of `m.key()`, not raw string literals. For each of the four newly migrated ad-hoc pages (documents list, themen, stammbaum, persons/review) add empty-state keys in `messages/de.json` (authored), `en.json`, and `es.json` (stubs acceptable for en/es on first ship). Key naming convention: `<page>_empty_heading` / `<page>_empty_subline`. The Chronik keys already exist inside `ChronikEmptyState` — keep them, move them to the caller (`aktivitaeten`). ## Tests Create `$lib/shared/primitives/EmptyState.svelte.spec.ts` (vitest-browser, copy pattern from `ChronikEmptyState.svelte.spec.ts`). Assert: - [ ] dashed border class present - [ ] `rounded-sm` present, `rounded-lg` absent - [ ] heading renders with `font-serif` class - [ ] subline ends with `…` - [ ] `action` slot renders when provided - [ ] heading/subline are NOT wrapped in `{@html}` (static grep check — confirm no `{@html}` in `EmptyState.svelte`) ## Acceptance - [ ] Matches DESIGN_RULES §7: `1px dashed var(--c-line)` border, `rounded-sm`, serif heading (20 px), Montserrat subline (13 px `text-ink-3`) ending `…`, no icon - [ ] Padding `py-12 px-6`; subline capped at `max-w-prose` — does not run edge-to-edge at 320 px - [ ] All five migration targets migrated; `ChronikEmptyState` deleted; its test files updated or removed - [ ] Each migrated page has Paraglide keys in de/en/es; no hardcoded string literals - [ ] `EmptyState.svelte.spec.ts` passes (dashed border, `font-serif`, `rounded-sm`, `…`, action slot, no `{@html}`) - [ ] `action` slot: focus-visible ring visible; keyboard-reachable - [ ] Visual diff at 320 / 768 / 1440 px in light AND dark on: Chronik, documents list, themen, stammbaum, persons/review — no regression - [ ] axe-core passes both themes; ≥ 4.5:1 contrast on heading and subline in light and dark; `focus-visible` preserved ## Out of Scope - Loading/skeleton states — not part of this primitive - Filter-empty vs first-run vs inbox-zero variant logic — stays in each caller - `TranscribeCoachEmptyState` — out of scope (help-coach variant, not a §7 empty state) - Any backend change, Flyway migration, new env var, or CI workflow change - Rewiring consumers beyond the five named migration targets **Depends on:** none. **Refs:** `DESIGN_RULES §7`, `_AUTHORING_KIT.md §10`, `$lib/shared/primitives/BackButton.svelte` (pattern).
marcel added this to the Mappe Visual Redesign milestone 2026-06-16 10:53:57 +02:00
marcel added the P2-mediumfeatureredesign-mappeui labels 2026-06-16 11:06:22 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#860