Build Galerie — responsive article grid with category filter #6

Open
opened 2026-05-05 10:57:24 +02:00 by marcel · 8 comments
Owner

Task 6 — Plan reference: docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md

User story (US-GAL-001 + US-GAL-002 + US-RES-003):

  • As a family member, I see all articles in a responsive grid.
  • I can filter by category.
  • I can see who has reserved an article (display name shown).

Acceptance criteria

  • Redirects to / if no valid family_code cookie
  • Phone (≤767px): 1-column list, horizontal cards, photo 72×72px on left
  • Tablet/Desktop (≥768px): 2-column grid, square cards, photo fills top
  • Desktop: max-width container, centered (no 3-column layout)
  • Sticky nav bar (green, bg-primary) shows "Erbstücke Wannsee" and logged-in display name
  • Sticky horizontal filter bar below nav: "Alle" + one pill per category, scrollable on overflow
  • Active filter pill: bg-primary text-white; inactive: border-line text-ink-muted
  • Status display per card:
    • Frei: text-status-free "✓ Frei"
    • My reservation: green badge "✓ Meine Reservierung"
    • Reserved by other: text-status-taken "● [DisplayName]"
  • Empty state when filter yields no results: centered message
  • All data loaded server-side (SSR) — no client-side fetch calls (NFR-PERF-001)
  • Clicking a card sets selectedId state → opens modal (placeholder for Task 7)

SQL query

Single JOIN query loads articles + reservation status + thumbnail filename in one round-trip.

Files to create

  • src/routes/galerie/+page.svelte
  • src/routes/galerie/+page.server.ts

Depends on: #3 | Size: M | Spec: reservierung-design §4.2, views spec View 02, NFR-PERF-001, NFR-RESP-001

## Task 6 — Plan reference: `docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md` **User story (US-GAL-001 + US-GAL-002 + US-RES-003):** - As a family member, I see all articles in a responsive grid. - I can filter by category. - I can see who has reserved an article (display name shown). ### Acceptance criteria - [ ] Redirects to `/` if no valid `family_code` cookie - [ ] Phone (≤767px): 1-column list, horizontal cards, photo 72×72px on left - [ ] Tablet/Desktop (≥768px): 2-column grid, square cards, photo fills top - [ ] Desktop: max-width container, centered (no 3-column layout) - [ ] Sticky nav bar (green, `bg-primary`) shows "Erbstücke Wannsee" and logged-in display name - [ ] Sticky horizontal filter bar below nav: "Alle" + one pill per category, scrollable on overflow - [ ] Active filter pill: `bg-primary text-white`; inactive: `border-line text-ink-muted` - [ ] Status display per card: - Frei: `text-status-free` "✓ Frei" - My reservation: green badge "✓ Meine Reservierung" - Reserved by other: `text-status-taken` "● [DisplayName]" - [ ] Empty state when filter yields no results: centered message - [ ] All data loaded server-side (SSR) — no client-side fetch calls (NFR-PERF-001) - [ ] Clicking a card sets `selectedId` state → opens modal (placeholder for Task 7) ### SQL query Single JOIN query loads articles + reservation status + thumbnail filename in one round-trip. ### Files to create - `src/routes/galerie/+page.svelte` - `src/routes/galerie/+page.server.ts` **Depends on:** #3 | **Size:** M | **Spec:** reservierung-design §4.2, views spec View 02, NFR-PERF-001, NFR-RESP-001
marcel added this to the v1.0 — MVP milestone 2026-05-05 10:57:24 +02:00
Author
Owner

👤 Markus Keller — Application Architect (SvelteKit · SQLite · Node.js)

Observations

  • The SQL query in +page.server.ts is written inline as an ad-hoc .prepare(...) call inside the load function, not as a named prepared statement defined at module-load time. This contradicts the architecture rule: prepare once at module load, reuse per request.
  • The +page.server.ts plan code does not export the prepared statement from lib/db.ts. Instead the query is defined and executed inside load — meaning it is re-parsed on every request. For 500 articles this is a measurable (if small) overhead, and more importantly it breaks the single-source-of-truth principle for all statements.
  • The route correctly reads locals.familyCode — auth enforcement is properly delegated to hooks.server.ts. No raw cookie reads inside the route. Good.
  • The plan puts the galerie/+page.svelte modal placeholder inline rather than in a separate component. That is explicitly scoped for Task 7, so it is acceptable as-is — but should not be merged long-term without the ArtikelModal.svelte component.
  • The selectedId state and selectedArtikel derived are stored in +page.svelte directly, which is the correct location for view-level state that does not need to survive navigation.
  • The plan's artikel/[id]/+page.server.ts handles both reservieren and aufheben — the action boundary for mutations is correctly placed outside the galerie route. Galerie page itself stays read-only. This is good architecture.
  • One concern: the catch block in reservieren silently swallows the UNIQUE constraint error and redirects as if success. The user receives no feedback that someone else beat them to the reservation. The issue acceptance criteria do not require a conflict message on the galerie itself (that is Task 7 scope), but the silent swallow in the action should at minimum pass an error signal.
  • No await on better-sqlite3 calls anywhere in the plan. Correct — the library is synchronous.

Recommendations

  • Move the gallery query to lib/db.ts as a named exported prepared statement:
    // lib/db.ts
    export const stmtGalerieArtikel = db.prepare(`
      SELECT a.id, a.titel, a.kategorie,
        (SELECT filename FROM artikel_fotos WHERE artikel_id = a.id ORDER BY position LIMIT 1) AS thumbnail,
        r.code_id AS reserviert_code_id, c.display_name AS reserviert_name
      FROM artikel a
      LEFT JOIN reservierungen r ON r.artikel_id = a.id
      LEFT JOIN codes c ON c.id = r.code_id
      ORDER BY a.created_at DESC
    `)
    
    Import and call in +page.server.ts with .all(). This is consistent with how stmtArtikelById is defined in the architect persona's examples.
  • The reservieren action catch block should return fail(409, { error: 'bereits_reserviert' }) rather than silently redirecting. Even if the galerie page doesn't display it yet, the structured error is the correct contract. Task 7 can surface it in the modal.
  • The path traversal check in the uploads route uses target.startsWith(base) without appending path.sep. Add path.sep to prevent /uploads-evil matching /uploads as a prefix:
    if (!target.startsWith(base + path.sep) || !existsSync(target)) error(404)
    
  • The sameSite: 'lax' in setFamilyCodeCookie should be 'strict' per the security architecture. lax allows cross-site GET requests to send the cookie — not needed here and broader than required.

Open Decisions

  • Prepared statement location — The plan defines all SQL inline in route files. The architecture specification says statements belong in lib/db.ts. One of these must win consistently. If inline is chosen for simplicity, that decision should be explicit and applied everywhere, not mixed.
## 👤 Markus Keller — Application Architect (SvelteKit · SQLite · Node.js) ### Observations - The SQL query in `+page.server.ts` is written inline as an ad-hoc `.prepare(...)` call inside the `load` function, not as a named prepared statement defined at module-load time. This contradicts the architecture rule: prepare once at module load, reuse per request. - The `+page.server.ts` plan code does not export the prepared statement from `lib/db.ts`. Instead the query is defined and executed inside `load` — meaning it is re-parsed on every request. For 500 articles this is a measurable (if small) overhead, and more importantly it breaks the single-source-of-truth principle for all statements. - The route correctly reads `locals.familyCode` — auth enforcement is properly delegated to `hooks.server.ts`. No raw cookie reads inside the route. Good. - The plan puts the `galerie/+page.svelte` modal placeholder inline rather than in a separate component. That is explicitly scoped for Task 7, so it is acceptable as-is — but should not be merged long-term without the `ArtikelModal.svelte` component. - The `selectedId` state and `selectedArtikel` derived are stored in `+page.svelte` directly, which is the correct location for view-level state that does not need to survive navigation. - The plan's `artikel/[id]/+page.server.ts` handles both `reservieren` and `aufheben` — the action boundary for mutations is correctly placed outside the galerie route. Galerie page itself stays read-only. This is good architecture. - One concern: the catch block in `reservieren` silently swallows the UNIQUE constraint error and redirects as if success. The user receives no feedback that someone else beat them to the reservation. The issue acceptance criteria do not require a conflict message on the galerie itself (that is Task 7 scope), but the silent swallow in the action should at minimum pass an error signal. - No `await` on `better-sqlite3` calls anywhere in the plan. Correct — the library is synchronous. ### Recommendations - Move the gallery query to `lib/db.ts` as a named exported prepared statement: ```typescript // lib/db.ts export const stmtGalerieArtikel = db.prepare(` SELECT a.id, a.titel, a.kategorie, (SELECT filename FROM artikel_fotos WHERE artikel_id = a.id ORDER BY position LIMIT 1) AS thumbnail, r.code_id AS reserviert_code_id, c.display_name AS reserviert_name FROM artikel a LEFT JOIN reservierungen r ON r.artikel_id = a.id LEFT JOIN codes c ON c.id = r.code_id ORDER BY a.created_at DESC `) ``` Import and call in `+page.server.ts` with `.all()`. This is consistent with how `stmtArtikelById` is defined in the architect persona's examples. - The `reservieren` action catch block should return `fail(409, { error: 'bereits_reserviert' })` rather than silently redirecting. Even if the galerie page doesn't display it yet, the structured error is the correct contract. Task 7 can surface it in the modal. - The path traversal check in the uploads route uses `target.startsWith(base)` without appending `path.sep`. Add `path.sep` to prevent `/uploads-evil` matching `/uploads` as a prefix: ```typescript if (!target.startsWith(base + path.sep) || !existsSync(target)) error(404) ``` - The `sameSite: 'lax'` in `setFamilyCodeCookie` should be `'strict'` per the security architecture. `lax` allows cross-site GET requests to send the cookie — not needed here and broader than required. ### Open Decisions - **Prepared statement location** — The plan defines all SQL inline in route files. The architecture specification says statements belong in `lib/db.ts`. One of these must win consistently. If inline is chosen for simplicity, that decision should be explicit and applied everywhere, not mixed.
Author
Owner

👤 Felix Brandt — Fullstack Developer (SvelteKit · TypeScript · SQLite · Node.js)

Observations

  • Missing key expression on {#each}: The plan's +page.svelte iterates {#each gefilterteArtikel as artikel} without a key expression. This causes position-based DOM reconciliation when the filter changes, which can corrupt local hover/focus state. Always: {#each gefilterteArtikel as artikel (artikel.id)}.
  • Business logic in template markup: The aria-label directly concatenates status logic inline:
    aria-label="{artikel.kategorie}, {artikel.status === 'frei' ? 'Frei' : artikel.status === 'meine' ? 'Meine Reservierung' : 'Reserviert von ' + artikel.reserviertVon}"
    
    This is the exact pattern the developer persona flags as wrong. Extract to $derived:
    const ariaLabel = $derived(`${artikel.kategorie}, ${
      artikel.status === 'frei' ? 'Frei' :
      artikel.status === 'meine' ? 'Meine Reservierung' :
      'Reserviert von ' + artikel.reserviertVon
    }`)
    
    But even better, per-card derived logic belongs inside a GalerieKarte.svelte component, not inline in the page.
  • No component split: The plan puts the full card markup (photo, category, status) inline in the page. The developer persona's file list explicitly names GalerieKarte.svelte and KategorieFilter.svelte as separate components. This issue creates those files as part of Task 6 — the plan skips that decomposition until Task 7, which is scope creep deferral rather than design.
  • {#if} for thumbnail presence: The placeholder <div class="bg-[#C4B8A8]"> for articles without a photo uses a hardcoded hex value (#C4B8A8) instead of a design token. All color values must come from Tailwind token names.
  • Status badge uses hardcoded hex: bg-[#E8F5EC] text-[#2E6645] border-[#A8D5B8] in the "Meine Reservierung" badge. These are not defined in the Tailwind config. Either define a reservation token set or use status-free variants already configured.
  • No TDD evidence for the galerie route: The plan has zero test steps for +page.server.ts. The load function is testable — it can be imported directly in Vitest with a mocked locals. This is the exact pattern described in the developer persona: "Test SvelteKit load functions by importing them directly."
  • sameSite: 'lax' on the family code cookie (from Task 3): The auth helper uses lax, the architecture and security specs require strict. This will affect galerie auth behavior on cross-site navigations.
  • The reservieren action swallows the UNIQUE constraint without returning a fail(). The developer persona's pattern is explicit: catch the constraint, return fail(409, { error: 'already_reserved' }). A silent redirect on conflict is incorrect.

Recommendations

  • Add key expressions to all {#each} loops immediately: {#each gefilterteArtikel as artikel (artikel.id)}.
  • Extract GalerieKarte.svelte now, not in a later task. The component boundary is clear: one card = one file. Pass exact props:
    <GalerieKarte
      id={artikel.id}
      titel={artikel.titel}
      kategorie={artikel.kategorie}
      thumbnail={artikel.thumbnail}
      status={artikel.status}
      reserviertVon={artikel.reserviertVon}
      onclick={() => (selectedId = artikel.id)}
    />
    
  • Extract KategorieFilter.svelte as well — the pill bar is its own visual region.
  • Replace all hardcoded hex in the template with Tailwind tokens. If the "Meine Reservierung" badge colors are not yet in tailwind.config.js, add them.
  • Write the failing integration test for the load function before implementing:
    // src/routes/galerie/galerie.test.ts
    it('redirects to gate when no family code', async () => {
      const event = makeTestEvent({ locals: { familyCode: null } })
      await expect(load(event)).rejects.toMatchObject({ status: 302 })
    })
    it('returns artikel array with correct status fields', () => { ... })
    
  • Fix the reservieren catch block: return fail(409, { error: 'bereits_reserviert' }), do not redirect silently.
## 👤 Felix Brandt — Fullstack Developer (SvelteKit · TypeScript · SQLite · Node.js) ### Observations - **Missing key expression on `{#each}`**: The plan's `+page.svelte` iterates `{#each gefilterteArtikel as artikel}` without a key expression. This causes position-based DOM reconciliation when the filter changes, which can corrupt local hover/focus state. Always: `{#each gefilterteArtikel as artikel (artikel.id)}`. - **Business logic in template markup**: The aria-label directly concatenates status logic inline: ```svelte aria-label="{artikel.kategorie}, {artikel.status === 'frei' ? 'Frei' : artikel.status === 'meine' ? 'Meine Reservierung' : 'Reserviert von ' + artikel.reserviertVon}" ``` This is the exact pattern the developer persona flags as wrong. Extract to `$derived`: ```svelte const ariaLabel = $derived(`${artikel.kategorie}, ${ artikel.status === 'frei' ? 'Frei' : artikel.status === 'meine' ? 'Meine Reservierung' : 'Reserviert von ' + artikel.reserviertVon }`) ``` But even better, per-card derived logic belongs inside a `GalerieKarte.svelte` component, not inline in the page. - **No component split**: The plan puts the full card markup (photo, category, status) inline in the page. The developer persona's file list explicitly names `GalerieKarte.svelte` and `KategorieFilter.svelte` as separate components. This issue creates those files as part of Task 6 — the plan skips that decomposition until Task 7, which is scope creep deferral rather than design. - **`{#if}` for thumbnail presence**: The placeholder `<div class="bg-[#C4B8A8]">` for articles without a photo uses a hardcoded hex value (`#C4B8A8`) instead of a design token. All color values must come from Tailwind token names. - **Status badge uses hardcoded hex**: `bg-[#E8F5EC] text-[#2E6645] border-[#A8D5B8]` in the "Meine Reservierung" badge. These are not defined in the Tailwind config. Either define a `reservation` token set or use `status-free` variants already configured. - **No TDD evidence for the galerie route**: The plan has zero test steps for `+page.server.ts`. The `load` function is testable — it can be imported directly in Vitest with a mocked `locals`. This is the exact pattern described in the developer persona: "Test SvelteKit load functions by importing them directly." - **`sameSite: 'lax'` on the family code cookie** (from Task 3): The auth helper uses `lax`, the architecture and security specs require `strict`. This will affect galerie auth behavior on cross-site navigations. - **The `reservieren` action** swallows the UNIQUE constraint without returning a `fail()`. The developer persona's pattern is explicit: catch the constraint, return `fail(409, { error: 'already_reserved' })`. A silent redirect on conflict is incorrect. ### Recommendations - Add key expressions to all `{#each}` loops immediately: `{#each gefilterteArtikel as artikel (artikel.id)}`. - Extract `GalerieKarte.svelte` now, not in a later task. The component boundary is clear: one card = one file. Pass exact props: ```svelte <GalerieKarte id={artikel.id} titel={artikel.titel} kategorie={artikel.kategorie} thumbnail={artikel.thumbnail} status={artikel.status} reserviertVon={artikel.reserviertVon} onclick={() => (selectedId = artikel.id)} /> ``` - Extract `KategorieFilter.svelte` as well — the pill bar is its own visual region. - Replace all hardcoded hex in the template with Tailwind tokens. If the "Meine Reservierung" badge colors are not yet in `tailwind.config.js`, add them. - Write the failing integration test for the load function before implementing: ```typescript // src/routes/galerie/galerie.test.ts it('redirects to gate when no family code', async () => { const event = makeTestEvent({ locals: { familyCode: null } }) await expect(load(event)).rejects.toMatchObject({ status: 302 }) }) it('returns artikel array with correct status fields', () => { ... }) ``` - Fix the `reservieren` catch block: return `fail(409, { error: 'bereits_reserviert' })`, do not redirect silently.
Author
Owner

👤 Leonie Voss — UI/UX Design Lead & Accessibility Advocate

Observations

  • Nav bar display name contrast: The logged-in name is rendered as text-white/75 (75% opacity white on primary green). On #5B7A66 background, white at 75% opacity is approximately #C6D4CB — which drops below the 4.5:1 WCAG AA threshold for normal-size text at 10px font-bold. Either use full text-white or increase font size to 14px minimum where contrast can be confirmed.
  • Category pill font size: Pills use text-[10px]. The design system spec states "minimum 12px for any visible text" with an exception for uppercase tracking-wide labels. The pills are uppercase + bold, so this is borderline acceptable — but min-h-[30px] touch target is below the required 44×44px minimum. Category filter pills must meet 44px height minimum since they are interactive elements on touch devices. Increase to min-h-[44px].
  • Category label font size in cards: text-[9px] font-extrabold uppercase tracking-[.6px] is below the absolute minimum. 9px is unreadable on any mobile device. Use text-[10px] at minimum, preferably text-xs (12px).
  • Artikel title font size: font-serif text-[12px] is the spec minimum — acceptable, but only just. This is the primary content label; consider text-sm (14px) for readability on phones.
  • Status text sizes: text-[10px] font-bold for "✓ Frei" and "● Name" — at the absolute minimum. Confirm these remain readable at 375px viewport with device-level text scaling.
  • No <main> landmark: The gallery content renders inside a <div class="p-2.5"> with no semantic <main> element. Screen readers use <main> to skip to content. Add <main> wrapping the article grid.
  • No <nav aria-label> on filter bar: The category filter <div> should be <nav aria-label="Kategorie-Filter"> to provide a navigable landmark for assistive technology.
  • No role="list" on the article grid: When CSS removes list semantics (which Tailwind's flex/grid on a <div> implies), the list must be reinstated via <ul> with role="list" and <li> wrappers per card. Screen readers announce item count on <ul role="list">.
  • Placeholder photo has no semantic fallback text: <div class="bg-[#C4B8A8]"> with no text alternative. Add aria-label="Kein Foto" or use a meaningful placeholder icon with sr-only text.
  • alt="" on the card thumbnail image: Empty alt is correct for decorative images, but these card thumbnails serve as the primary visual identifier for each article — they are not decorative. Use alt="{artikel.titel ?? artikel.kategorie}" to give screen reader users equivalent information.
  • Placeholder modal has no focus trap: The Task 6 placeholder <div role="dialog"> does not trap focus. Until ArtikelModal.svelte (Task 7) is in place, the modal should either use a native <dialog> element (which provides focus trapping built-in) or not be interactive. A <div role="dialog"> without focus management fails WCAG 2.1 SC 2.1.2.
  • Card <button> has no visible focus indicator: Tailwind resets focus styles by default. The card buttons need focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2.

Recommendations

  • Change the nav display name to text-white (remove opacity) and verify contrast with WebAIM checker on #5B7A66.
  • Set all filter pills to min-h-[44px] and px-3 py-2.5 for adequate touch target.
  • Replace text-[9px] category labels in cards with text-[11px] minimum (or Tailwind text-xs).
  • Wrap the card grid in <main> and the filter bar in <nav aria-label="Kategorie-Filter">.
  • Change the card iteration from <div class="flex flex-col gap-2 ..."> wrapping <button> elements to a <ul role="list" class="flex flex-col gap-2 ..."> with each card inside <li>.
  • Add alt="{artikel.titel ?? artikel.kategorie}" to card thumbnail images.
  • Add focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 to the card button.
  • Replace the <div role="dialog"> placeholder with a native <dialog> element — browsers provide focus trapping, Escape-to-close, and ARIA semantics automatically.
  • Use Lora for article titles in cards (already font-serif— correct) and confirm Inter is applied to all badge/label/button text.

Open Decisions

  • Card thumbnail alt text — Empty alt (alt="") treats thumbnails as decorative, but they are the primary visual content differentiator. The spec does not mandate alt text per article. Decide: require admins to enter a title (which becomes alt text), or generate generic alt like "Foto von Artikel in Kategorie [X]". This affects the data model (title currently optional).
## 👤 Leonie Voss — UI/UX Design Lead & Accessibility Advocate ### Observations - **Nav bar display name contrast**: The logged-in name is rendered as `text-white/75` (75% opacity white on primary green). On `#5B7A66` background, white at 75% opacity is approximately `#C6D4CB` — which drops below the 4.5:1 WCAG AA threshold for normal-size text at `10px font-bold`. Either use full `text-white` or increase font size to 14px minimum where contrast can be confirmed. - **Category pill font size**: Pills use `text-[10px]`. The design system spec states "minimum 12px for any visible text" with an exception for uppercase tracking-wide labels. The pills are uppercase + bold, so this is borderline acceptable — but `min-h-[30px]` touch target is below the required 44×44px minimum. Category filter pills must meet 44px height minimum since they are interactive elements on touch devices. Increase to `min-h-[44px]`. - **Category label font size in cards**: `text-[9px] font-extrabold uppercase tracking-[.6px]` is below the absolute minimum. 9px is unreadable on any mobile device. Use `text-[10px]` at minimum, preferably `text-xs` (12px). - **Artikel title font size**: `font-serif text-[12px]` is the spec minimum — acceptable, but only just. This is the primary content label; consider `text-sm` (14px) for readability on phones. - **Status text sizes**: `text-[10px] font-bold` for "✓ Frei" and "● Name" — at the absolute minimum. Confirm these remain readable at 375px viewport with device-level text scaling. - **No `<main>` landmark**: The gallery content renders inside a `<div class="p-2.5">` with no semantic `<main>` element. Screen readers use `<main>` to skip to content. Add `<main>` wrapping the article grid. - **No `<nav aria-label>` on filter bar**: The category filter `<div>` should be `<nav aria-label="Kategorie-Filter">` to provide a navigable landmark for assistive technology. - **No `role="list"` on the article grid**: When CSS removes list semantics (which Tailwind's `flex`/`grid` on a `<div>` implies), the list must be reinstated via `<ul>` with `role="list"` and `<li>` wrappers per card. Screen readers announce item count on `<ul role="list">`. - **Placeholder photo has no semantic fallback text**: `<div class="bg-[#C4B8A8]">` with no text alternative. Add `aria-label="Kein Foto"` or use a meaningful placeholder icon with sr-only text. - **`alt=""` on the card thumbnail image**: Empty alt is correct for decorative images, but these card thumbnails serve as the primary visual identifier for each article — they are not decorative. Use `alt="{artikel.titel ?? artikel.kategorie}"` to give screen reader users equivalent information. - **Placeholder modal has no focus trap**: The Task 6 placeholder `<div role="dialog">` does not trap focus. Until ArtikelModal.svelte (Task 7) is in place, the modal should either use a native `<dialog>` element (which provides focus trapping built-in) or not be interactive. A `<div role="dialog">` without focus management fails WCAG 2.1 SC 2.1.2. - **Card `<button>` has no visible focus indicator**: Tailwind resets focus styles by default. The card buttons need `focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2`. ### Recommendations - Change the nav display name to `text-white` (remove opacity) and verify contrast with WebAIM checker on `#5B7A66`. - Set all filter pills to `min-h-[44px]` and `px-3 py-2.5` for adequate touch target. - Replace `text-[9px]` category labels in cards with `text-[11px]` minimum (or Tailwind `text-xs`). - Wrap the card grid in `<main>` and the filter bar in `<nav aria-label="Kategorie-Filter">`. - Change the card iteration from `<div class="flex flex-col gap-2 ...">` wrapping `<button>` elements to a `<ul role="list" class="flex flex-col gap-2 ...">` with each card inside `<li>`. - Add `alt="{artikel.titel ?? artikel.kategorie}"` to card thumbnail images. - Add `focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2` to the card button. - Replace the `<div role="dialog">` placeholder with a native `<dialog>` element — browsers provide focus trapping, Escape-to-close, and ARIA semantics automatically. - Use Lora for article titles in cards (already `font-serif`— correct) and confirm Inter is applied to all badge/label/button text. ### Open Decisions - **Card thumbnail alt text** — Empty alt (`alt=""`) treats thumbnails as decorative, but they are the primary visual content differentiator. The spec does not mandate alt text per article. Decide: require admins to enter a title (which becomes alt text), or generate generic alt like "Foto von Artikel in Kategorie [X]". This affects the data model (title currently optional).
Author
Owner

👤 Sara Holt — QA Engineer & Test Strategist

Observations

  • Zero test steps for Task 6: The plan's Task 6 has no - [ ] Step N: Write the failing test step, making it the only task in the plan that completely skips TDD. Tasks 2, 3, and 4 all start with a failing test — Task 6 breaks that pattern without explanation.
  • No integration test for the load function: The galerie/+page.server.ts load function is the most complex data-shaping code in this task (join query, status mapping, redirect guard). It is importable TypeScript and trivially testable with an in-memory SQLite db. There is no reason to skip it.
  • No component tests planned: The card renders three distinct visual states (frei, meine, belegt). These are precisely the states vitest-browser-svelte component tests are designed for. The tester persona's examples literally show GalerieKarte as the specimen.
  • No E2E journey addition: The gallery is a critical user journey — "family member sees articles, filters by category, sees reservation status." The E2E suite definition in the tester persona lists this journey. Task 6 should specify which E2E test to add or update.
  • reservieren action catch block swallows errors silently: There is no test that verifies the UNIQUE constraint actually prevents double reservation and surfaces a user-facing response. The architect persona's db tests cover the constraint at the SQLite level, but the Form Action's response contract (the fail(409)) has no test.
  • No auth boundary test for galerie: The issue acceptance criteria state "Redirects to / if no valid family_code cookie." This redirect must have an integration test. Without it, a future change to hooks.server.ts could silently break the guard.
  • Manual test steps only: Step 3 says "Seed some articles and a code, log in, verify..." — this is human-time verification with no automated equivalent. For a gallery that is the entire family-facing surface, manual-only testing is insufficient.

Recommendations

Add the following test steps to Task 6 before Step 2 (implement the svelte file):

Step 1a — Integration tests for +page.server.ts (write failing first):

// src/routes/galerie/galerie.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { getDb, _resetForTesting } from '$lib/db'

beforeEach(() => {
  process.env.DATABASE_PATH = ':memory:'
  _resetForTesting()
})

it('redirects to gate when family_code is null', async () => {
  const { load } = await import('./+page.server')
  await expect(load({ locals: { familyCode: null } } as any))
    .rejects.toMatchObject({ status: 302, location: '/' })
})

it('returns artikel with status frei when not reserved', async () => {
  const db = getDb()
  db.prepare("INSERT INTO codes (code, display_name) VALUES ('TEST0001', 'Alice')").run()
  db.prepare("INSERT INTO artikel (kategorie) VALUES ('Bücher')").run()
  const { load } = await import('./+page.server')
  const result = await load({ locals: { familyCode: { id: 1, code: 'TEST0001', displayName: 'Alice' } } } as any)
  expect(result.artikel[0].status).toBe('frei')
})

it('returns status meine when reserved by current code', async () => { ... })
it('returns status belegt with reserviertVon name for other reservations', async () => { ... })

Step 2a — Component tests for GalerieKarte (requires extracting the component):

import { render } from 'vitest-browser-svelte'
import GalerieKarte from '$lib/components/GalerieKarte.svelte'

it('shows "✓ Frei" for unreserved article', async () => {
  const { getByText } = render(GalerieKarte, { props: { status: 'frei', ... } })
  await expect.element(getByText('✓ Frei')).toBeVisible()
})

it('shows "✓ Meine Reservierung" badge for own reservation', async () => { ... })
it('shows reserved-by name with bullet for other reservation', async () => { ... })

Add to E2E critical journeys list:

  • "Family member enters gallery, sees all articles with correct status, filters by category, sees empty state message when no results"

Open Decisions

  • Test isolation for galerie route: The load function imports getDb() at module level. Testing it directly requires the _resetForTesting export and careful module cache handling. Decide whether to inject db as a parameter (more testable) or use the reset pattern (simpler). The reset pattern is already established in Tasks 2–4, so use it — but this must be explicit in the task steps.
## 👤 Sara Holt — QA Engineer & Test Strategist ### Observations - **Zero test steps for Task 6**: The plan's Task 6 has no `- [ ] Step N: Write the failing test` step, making it the only task in the plan that completely skips TDD. Tasks 2, 3, and 4 all start with a failing test — Task 6 breaks that pattern without explanation. - **No integration test for the `load` function**: The `galerie/+page.server.ts` load function is the most complex data-shaping code in this task (join query, status mapping, redirect guard). It is importable TypeScript and trivially testable with an in-memory SQLite db. There is no reason to skip it. - **No component tests planned**: The card renders three distinct visual states (`frei`, `meine`, `belegt`). These are precisely the states `vitest-browser-svelte` component tests are designed for. The tester persona's examples literally show `GalerieKarte` as the specimen. - **No E2E journey addition**: The gallery is a critical user journey — "family member sees articles, filters by category, sees reservation status." The E2E suite definition in the tester persona lists this journey. Task 6 should specify which E2E test to add or update. - **`reservieren` action catch block swallows errors silently**: There is no test that verifies the UNIQUE constraint actually prevents double reservation and surfaces a user-facing response. The architect persona's db tests cover the constraint at the SQLite level, but the Form Action's response contract (the `fail(409)`) has no test. - **No auth boundary test for galerie**: The issue acceptance criteria state "Redirects to `/` if no valid `family_code` cookie." This redirect must have an integration test. Without it, a future change to `hooks.server.ts` could silently break the guard. - **Manual test steps only**: Step 3 says "Seed some articles and a code, log in, verify..." — this is human-time verification with no automated equivalent. For a gallery that is the entire family-facing surface, manual-only testing is insufficient. ### Recommendations Add the following test steps to Task 6 before Step 2 (implement the svelte file): **Step 1a — Integration tests for `+page.server.ts`** (write failing first): ```typescript // src/routes/galerie/galerie.test.ts import { describe, it, expect, beforeEach } from 'vitest' import { getDb, _resetForTesting } from '$lib/db' beforeEach(() => { process.env.DATABASE_PATH = ':memory:' _resetForTesting() }) it('redirects to gate when family_code is null', async () => { const { load } = await import('./+page.server') await expect(load({ locals: { familyCode: null } } as any)) .rejects.toMatchObject({ status: 302, location: '/' }) }) it('returns artikel with status frei when not reserved', async () => { const db = getDb() db.prepare("INSERT INTO codes (code, display_name) VALUES ('TEST0001', 'Alice')").run() db.prepare("INSERT INTO artikel (kategorie) VALUES ('Bücher')").run() const { load } = await import('./+page.server') const result = await load({ locals: { familyCode: { id: 1, code: 'TEST0001', displayName: 'Alice' } } } as any) expect(result.artikel[0].status).toBe('frei') }) it('returns status meine when reserved by current code', async () => { ... }) it('returns status belegt with reserviertVon name for other reservations', async () => { ... }) ``` **Step 2a — Component tests for GalerieKarte** (requires extracting the component): ```typescript import { render } from 'vitest-browser-svelte' import GalerieKarte from '$lib/components/GalerieKarte.svelte' it('shows "✓ Frei" for unreserved article', async () => { const { getByText } = render(GalerieKarte, { props: { status: 'frei', ... } }) await expect.element(getByText('✓ Frei')).toBeVisible() }) it('shows "✓ Meine Reservierung" badge for own reservation', async () => { ... }) it('shows reserved-by name with bullet for other reservation', async () => { ... }) ``` **Add to E2E critical journeys list**: - "Family member enters gallery, sees all articles with correct status, filters by category, sees empty state message when no results" ### Open Decisions - **Test isolation for galerie route**: The `load` function imports `getDb()` at module level. Testing it directly requires the `_resetForTesting` export and careful module cache handling. Decide whether to inject `db` as a parameter (more testable) or use the reset pattern (simpler). The reset pattern is already established in Tasks 2–4, so use it — but this must be explicit in the task steps.
Author
Owner

👤 Nora Steiner ("NullX") — Application Security Engineer

Observations

1. Cookie SameSite downgraded from Strict to Lax (CWE-1004 / Security Smell)

The plan's setFamilyCodeCookie in lib/auth.ts uses sameSite: 'lax'. The security architecture spec and the architect persona both specify sameSite: 'strict'. lax allows the cookie to be sent on cross-site top-level GET navigations — meaning a link from any external site to /galerie would include the family_code cookie. For a reservation app, strict is correct: it forces re-authentication on cross-site navigation, which is the intended behavior.

Fix:

export function setFamilyCodeCookie(cookies: Cookies, code: string): void {
  cookies.set('family_code', code, {
    httpOnly: true,
    sameSite: 'strict',  // not 'lax'
    path: '/',
    maxAge: 60 * 60 * 24 * 365,
  })
}

2. Upload path traversal check missing path.sep (CWE-22 — Path Traversal)

The uploads route in the plan:

if (!target.startsWith(base) || !existsSync(target)) error(404)

target.startsWith(base) is insufficient. If UPLOAD_DIR resolves to /app/uploads, then a request for /uploads/../../app/uploads-evil/secret.webp resolves base = /app/uploads and target = /app/uploads-evil/secret.webp, which does NOT start with /app/uploads — so this specific example is caught. However the correct defensive pattern is always:

if (!target.startsWith(base + path.sep) || !existsSync(target)) error(400, 'Invalid path')

Using path.sep eliminates the class of prefix-confusion attacks entirely, and returning 400 rather than 404 does not leak whether the file exists outside the upload root.

3. reservieren action silently swallows all errors (CWE-390)

try {
  db.prepare('INSERT INTO reservierungen ...').run(params.id, locals.familyCode.id)
} catch {
  // UNIQUE constraint violated: someone else just reserved it
}
redirect(302, `/galerie?id=${params.id}`)

The bare catch {} swallows ALL exceptions, not only UNIQUE constraint violations. A database connection error, a permissions error, or any unexpected exception is silently consumed and the user is redirected as if success. This masks real failures.

Fix — distinguish the specific constraint error:

try {
  db.prepare('INSERT INTO reservierungen (artikel_id, code_id) VALUES (?, ?)').run(params.id, locals.familyCode.id)
} catch (e: unknown) {
  if (e instanceof Error && e.message.includes('UNIQUE constraint failed')) {
    return fail(409, { error: 'bereits_reserviert' })
  }
  throw e  // re-throw unexpected errors so they surface in logs
}

4. Admin session cookie not verified against database (Security Architecture Violation)

The hooks.server.ts plan code:

event.locals.admin = event.cookies.get('admin_session') ?? null

This trusts the cookie value directly. The security architecture specification requires whitelisting against known admin names:

const adminSession = event.cookies.get('admin_session')
if (adminSession && ADMIN_NAMES.includes(adminSession)) {
  event.locals.admin = adminSession
}

Without the whitelist check, an attacker who can forge or modify cookies could set admin_session to any value and gain locals.admin trust. The whitelist is thin protection but it is the specified pattern and must be followed.

5. No secure: !dev flag on cookies

Both setFamilyCodeCookie and setAdminCookie in the plan omit the secure flag. Without secure: true in production, cookies are transmitted over plain HTTP, which is accessible to network-level attackers. The Caddy reverse proxy provides TLS but the cookie flag must still be set at the application layer:

import { dev } from '$app/environment'
cookies.set('family_code', code, {
  httpOnly: true,
  sameSite: 'strict',
  secure: !dev,
  path: '/',
  maxAge: 60 * 60 * 24 * 365,
})

Recommendations

  • Fix sameSite: 'lax''strict' in both cookie setters in lib/auth.ts.
  • Add secure: !dev to both cookie setters.
  • Add path.sep to the uploads path traversal check and change the error code to 400.
  • Fix the bare catch {} in the reservieren action to distinguish constraint errors from unexpected errors, and re-throw the latter.
  • Add ADMIN_NAMES.includes() whitelist check in hooks.server.ts before assigning locals.admin.
  • Add a regression test for each of these:
    test('rejects path traversal in uploads route', async ({ page }) => {
      const res = await page.request.get('/uploads/../../etc/passwd')
      expect(res.status()).toBe(400)
    })
    it('admin_session cookie with non-admin value does not grant admin access', async () => { ... })
    
## 👤 Nora Steiner ("NullX") — Application Security Engineer ### Observations **1. Cookie SameSite downgraded from Strict to Lax (CWE-1004 / Security Smell)** The plan's `setFamilyCodeCookie` in `lib/auth.ts` uses `sameSite: 'lax'`. The security architecture spec and the architect persona both specify `sameSite: 'strict'`. `lax` allows the cookie to be sent on cross-site top-level GET navigations — meaning a link from any external site to `/galerie` would include the `family_code` cookie. For a reservation app, `strict` is correct: it forces re-authentication on cross-site navigation, which is the intended behavior. Fix: ```typescript export function setFamilyCodeCookie(cookies: Cookies, code: string): void { cookies.set('family_code', code, { httpOnly: true, sameSite: 'strict', // not 'lax' path: '/', maxAge: 60 * 60 * 24 * 365, }) } ``` **2. Upload path traversal check missing `path.sep` (CWE-22 — Path Traversal)** The uploads route in the plan: ```typescript if (!target.startsWith(base) || !existsSync(target)) error(404) ``` `target.startsWith(base)` is insufficient. If `UPLOAD_DIR` resolves to `/app/uploads`, then a request for `/uploads/../../app/uploads-evil/secret.webp` resolves `base` = `/app/uploads` and `target` = `/app/uploads-evil/secret.webp`, which does NOT start with `/app/uploads` — so this specific example is caught. However the correct defensive pattern is always: ```typescript if (!target.startsWith(base + path.sep) || !existsSync(target)) error(400, 'Invalid path') ``` Using `path.sep` eliminates the class of prefix-confusion attacks entirely, and returning `400` rather than `404` does not leak whether the file exists outside the upload root. **3. `reservieren` action silently swallows all errors (CWE-390)** ```typescript try { db.prepare('INSERT INTO reservierungen ...').run(params.id, locals.familyCode.id) } catch { // UNIQUE constraint violated: someone else just reserved it } redirect(302, `/galerie?id=${params.id}`) ``` The bare `catch {}` swallows ALL exceptions, not only `UNIQUE constraint` violations. A database connection error, a permissions error, or any unexpected exception is silently consumed and the user is redirected as if success. This masks real failures. Fix — distinguish the specific constraint error: ```typescript try { db.prepare('INSERT INTO reservierungen (artikel_id, code_id) VALUES (?, ?)').run(params.id, locals.familyCode.id) } catch (e: unknown) { if (e instanceof Error && e.message.includes('UNIQUE constraint failed')) { return fail(409, { error: 'bereits_reserviert' }) } throw e // re-throw unexpected errors so they surface in logs } ``` **4. Admin session cookie not verified against database (Security Architecture Violation)** The `hooks.server.ts` plan code: ```typescript event.locals.admin = event.cookies.get('admin_session') ?? null ``` This trusts the cookie value directly. The security architecture specification requires whitelisting against known admin names: ```typescript const adminSession = event.cookies.get('admin_session') if (adminSession && ADMIN_NAMES.includes(adminSession)) { event.locals.admin = adminSession } ``` Without the whitelist check, an attacker who can forge or modify cookies could set `admin_session` to any value and gain `locals.admin` trust. The whitelist is thin protection but it is the specified pattern and must be followed. **5. No `secure: !dev` flag on cookies** Both `setFamilyCodeCookie` and `setAdminCookie` in the plan omit the `secure` flag. Without `secure: true` in production, cookies are transmitted over plain HTTP, which is accessible to network-level attackers. The Caddy reverse proxy provides TLS but the cookie flag must still be set at the application layer: ```typescript import { dev } from '$app/environment' cookies.set('family_code', code, { httpOnly: true, sameSite: 'strict', secure: !dev, path: '/', maxAge: 60 * 60 * 24 * 365, }) ``` ### Recommendations - Fix `sameSite: 'lax'` → `'strict'` in both cookie setters in `lib/auth.ts`. - Add `secure: !dev` to both cookie setters. - Add `path.sep` to the uploads path traversal check and change the error code to `400`. - Fix the bare `catch {}` in the `reservieren` action to distinguish constraint errors from unexpected errors, and re-throw the latter. - Add `ADMIN_NAMES.includes()` whitelist check in `hooks.server.ts` before assigning `locals.admin`. - Add a regression test for each of these: ```typescript test('rejects path traversal in uploads route', async ({ page }) => { const res = await page.request.get('/uploads/../../etc/passwd') expect(res.status()).toBe(400) }) it('admin_session cookie with non-admin value does not grant admin access', async () => { ... }) ```
Author
Owner

👤 Tobias Wendt — DevOps & Platform Engineer

Observations

  • Task 6 has no infrastructure implications on its own — it creates two route files and reads from the database. No new volumes, no new environment variables, no new ports. The deployment topology (Caddy + single container) is unaffected.
  • Tailwind CSS 3 + PostCSS is specified in the plan — the tailwind.config.js and postcss.config.js from Task 1 must be present and correct before the gallery Tailwind classes render. This is a build-time dependency, not a runtime one, and it is already covered in Task 1. No blockers here.
  • The plan specifies Tailwind CSS 3 in the tech stack line. Tailwind 4 is available and changes the config format significantly. Task 1's tailwind.config.js uses the Tailwind 3 format (theme.extend.colors, plugins: []). Confirm the installed version is v3 before implementing to avoid silent class breakage.
  • No SESSION_SECRET startup guard in hooks.server.ts: The plan code does not fail to start when SESSION_SECRET is absent. The security architecture (and the architect persona) both specify that the app must process.exit(1) if SESSION_SECRET is missing. This is a deployment safety requirement — without it, a misconfigured container starts and serves traffic with no secret. Add to hooks.server.ts or lib/db.ts startup block:
    if (!process.env.SESSION_SECRET) {
      console.error('FATAL: SESSION_SECRET environment variable is required')
      process.exit(1)
    }
    
  • No /health endpoint yet: The docker-compose.yml plan healthcheck calls wget -qO- http://localhost:3000/health. No route exists yet that returns 200 OK on /health. This means the container will be reported as unhealthy until that route is added. It should be a single-file route: src/routes/health/+server.ts returning new Response('OK').
  • Multi-stage Dockerfile is specified but not yet created: The plan references a Dockerfile in the file map but no task creates it. Before the project can be deployed, someone needs to verify the multi-stage build actually works with @sveltejs/adapter-node. In particular: better-sqlite3 requires native compilation — confirm that npm ci --omit=dev in the runner stage either bundles the prebuilt binary or triggers a rebuild correctly.

Recommendations

  • Add SESSION_SECRET presence check to the app startup. Do it in src/hooks.server.ts at the top of the file before any other logic — it runs on every request and will catch the misconfiguration immediately on first load.
  • Create the /health route before deploying: src/routes/health/+server.ts:
    import type { RequestHandler } from './$types'
    export const GET: RequestHandler = () => new Response('OK', { status: 200 })
    
  • Pin tailwindcss to ^3.x explicitly in package.json if the project has not done so. Running npm install tailwindcss today might install v4 which has an incompatible config format.
  • Verify better-sqlite3 builds correctly in the multi-stage Docker image before the first full deploy. The native addon requires Python and a C compiler in the builder stage — node:22-alpine has these via apk add python3 make g++. Add to the Dockerfile builder stage if not already present.
  • Document the DATABASE_PATH and UPLOAD_DIR environment variable requirements in .env.example before the first deployment run.

Open Decisions (omit if none)

  • Tailwind version: The plan says "Tailwind CSS 3" but does not lock the version. If a contributor runs npm install fresh, they may get v4. Decide whether to lock to tailwindcss@3 in package.json dependencies or migrate the config to v4 format. Either is fine — but it must be decided before Task 1 runs, not discovered when the gallery classes fail to render.
## 👤 Tobias Wendt — DevOps & Platform Engineer ### Observations - **Task 6 has no infrastructure implications on its own** — it creates two route files and reads from the database. No new volumes, no new environment variables, no new ports. The deployment topology (Caddy + single container) is unaffected. - **Tailwind CSS 3 + PostCSS is specified in the plan** — the `tailwind.config.js` and `postcss.config.js` from Task 1 must be present and correct before the gallery Tailwind classes render. This is a build-time dependency, not a runtime one, and it is already covered in Task 1. No blockers here. - **The plan specifies `Tailwind CSS 3`** in the tech stack line. Tailwind 4 is available and changes the config format significantly. Task 1's `tailwind.config.js` uses the Tailwind 3 format (`theme.extend.colors`, `plugins: []`). Confirm the installed version is v3 before implementing to avoid silent class breakage. - **No `SESSION_SECRET` startup guard in `hooks.server.ts`**: The plan code does not fail to start when `SESSION_SECRET` is absent. The security architecture (and the architect persona) both specify that the app must `process.exit(1)` if `SESSION_SECRET` is missing. This is a deployment safety requirement — without it, a misconfigured container starts and serves traffic with no secret. Add to `hooks.server.ts` or `lib/db.ts` startup block: ```typescript if (!process.env.SESSION_SECRET) { console.error('FATAL: SESSION_SECRET environment variable is required') process.exit(1) } ``` - **No `/health` endpoint yet**: The `docker-compose.yml` plan healthcheck calls `wget -qO- http://localhost:3000/health`. No route exists yet that returns 200 OK on `/health`. This means the container will be reported as `unhealthy` until that route is added. It should be a single-file route: `src/routes/health/+server.ts` returning `new Response('OK')`. - **Multi-stage Dockerfile is specified but not yet created**: The plan references a `Dockerfile` in the file map but no task creates it. Before the project can be deployed, someone needs to verify the multi-stage build actually works with `@sveltejs/adapter-node`. In particular: `better-sqlite3` requires native compilation — confirm that `npm ci --omit=dev` in the runner stage either bundles the prebuilt binary or triggers a rebuild correctly. ### Recommendations - Add `SESSION_SECRET` presence check to the app startup. Do it in `src/hooks.server.ts` at the top of the file before any other logic — it runs on every request and will catch the misconfiguration immediately on first load. - Create the `/health` route before deploying: `src/routes/health/+server.ts`: ```typescript import type { RequestHandler } from './$types' export const GET: RequestHandler = () => new Response('OK', { status: 200 }) ``` - Pin `tailwindcss` to `^3.x` explicitly in `package.json` if the project has not done so. Running `npm install tailwindcss` today might install v4 which has an incompatible config format. - Verify `better-sqlite3` builds correctly in the multi-stage Docker image before the first full deploy. The native addon requires Python and a C compiler in the builder stage — `node:22-alpine` has these via `apk add python3 make g++`. Add to the Dockerfile builder stage if not already present. - Document the `DATABASE_PATH` and `UPLOAD_DIR` environment variable requirements in `.env.example` before the first deployment run. ### Open Decisions _(omit if none)_ - **Tailwind version**: The plan says "Tailwind CSS 3" but does not lock the version. If a contributor runs `npm install` fresh, they may get v4. Decide whether to lock to `tailwindcss@3` in `package.json` dependencies or migrate the config to v4 format. Either is fine — but it must be decided before Task 1 runs, not discovered when the gallery classes fail to render.
Author
Owner

👤 Elicit — Requirements Engineer & Business Analyst

Observations

  • Acceptance criteria are well-formed and specific: The issue covers the key observable behaviors — viewport layouts, nav bar, filter bar, status variants, empty state, SSR requirement, and the modal hook. These are testable as written. No vague language detected.
  • US-RES-003 is covered but underspecified for the "belegt" state: The issue says "Reserved by other: text-status-taken ● [DisplayName]" — but does not specify the fallback when reserviertVon is null. In the data model, display_name is NOT NULL, so this cannot be null in practice. However the TypeScript type in the plan has reserviert_name: string | null. The implementation must handle the null case even if the schema prevents it, to avoid a runtime ● null rendering.
  • selectedId state → opens modal (placeholder for Task 7): This acceptance criterion creates an implicit dependency: Task 6 merges with a non-functional modal stub. If Task 7 is blocked or delayed, Task 6 ships with a broken user experience (clicking any card opens a useless overlay). This dependency is noted in the issue (Depends on: #3) but the task boundary implies Task 6 is independently shippable — which it is not from a user perspective.
  • NFR-PERF-001 (500 articles, smooth scroll) is referenced but has no verification step: The acceptance criteria mention "All data loaded server-side (SSR) — no client-side fetch calls" as the implementation of NFR-PERF-001, but there is no test or measurement that confirms 500 articles render within an acceptable time. The NFR has no measurable threshold beyond "flüssig scrollen" (smooth scroll).
  • NFR-RESP-001 breakpoints are correctly specified: Phone ≤767px, Tablet/Desktop ≥768px, Desktop max-width container. The issue AC exactly matches the spec. No ambiguity.
  • Missing AC: what happens when the article list is completely empty (zero articles in DB, not just filtered): The empty state AC covers the filtered case ("Empty state when filter yields no results") but not the zero-articles case. These may need different messages: "Noch keine Artikel vorhanden" vs. "Keine Artikel in dieser Kategorie."
  • "Logged-in display name shown" (US-RES-003): The nav bar AC says "shows logged-in display name." The plan renders {data.displayName} in the nav. This satisfies the story. But the story also says family members can "see who has reserved an article." The display name is shown on the card — this is correct. No gap.
  • The issue size is marked M: Given the number of components, accessibility requirements, SSR data loading, and SQL join, M is accurate for the happy path. Add testing (currently absent from the plan) and the size becomes L.

Recommendations

  • Add an explicit AC: "When zero articles exist in the database, a centered message reads 'Noch keine Artikel vorhanden.'" to distinguish from the filtered-empty state.
  • Add a measurable threshold to NFR-PERF-001 for this task: "The galerie page HTML response time must be under 500ms for up to 500 articles on the test server." Without a number, the NFR cannot be verified.
  • Add a guard in the +page.svelte template for reserviertVon being null even though the schema prevents it:
    <div class="text-[10px] font-bold text-status-taken">{artikel.reserviertVon ?? 'Unbekannt'}</div>
    
    This prevents a ● null from ever reaching the DOM.
  • The dependency on Task 7 (modal) should be tracked explicitly: either add "Depends on: #7" to this issue once Task 7 is created, or scope Task 6 to not include the selectedId state and modal placeholder until Task 7 is ready to land simultaneously.

Open Decisions

  • Empty state messaging: Two distinct empty states exist (zero articles total vs. zero articles for category filter). The spec does not distinguish between them. Decide on one message per state before implementation to avoid inconsistent German copy between tasks.
  • NFR-PERF-001 measurable threshold: "Flüssig scrollen" is not a testable requirement. Agree on a response time target (e.g., "page load under 500ms for 500 articles on a mid-range Android phone on WiFi") so it can be verified before launch.
## 👤 Elicit — Requirements Engineer & Business Analyst ### Observations - **Acceptance criteria are well-formed and specific**: The issue covers the key observable behaviors — viewport layouts, nav bar, filter bar, status variants, empty state, SSR requirement, and the modal hook. These are testable as written. No vague language detected. - **US-RES-003 is covered but underspecified for the "belegt" state**: The issue says "Reserved by other: `text-status-taken` `● [DisplayName]`" — but does not specify the fallback when `reserviertVon` is `null`. In the data model, `display_name` is `NOT NULL`, so this cannot be null in practice. However the TypeScript type in the plan has `reserviert_name: string | null`. The implementation must handle the null case even if the schema prevents it, to avoid a runtime `● null` rendering. - **`selectedId` state → opens modal (placeholder for Task 7)**: This acceptance criterion creates an implicit dependency: Task 6 merges with a non-functional modal stub. If Task 7 is blocked or delayed, Task 6 ships with a broken user experience (clicking any card opens a useless overlay). This dependency is noted in the issue (`Depends on: #3`) but the task boundary implies Task 6 is independently shippable — which it is not from a user perspective. - **NFR-PERF-001 (500 articles, smooth scroll) is referenced but has no verification step**: The acceptance criteria mention "All data loaded server-side (SSR) — no client-side fetch calls" as the implementation of NFR-PERF-001, but there is no test or measurement that confirms 500 articles render within an acceptable time. The NFR has no measurable threshold beyond "flüssig scrollen" (smooth scroll). - **NFR-RESP-001 breakpoints are correctly specified**: Phone ≤767px, Tablet/Desktop ≥768px, Desktop max-width container. The issue AC exactly matches the spec. No ambiguity. - **Missing AC: what happens when the article list is completely empty** (zero articles in DB, not just filtered): The empty state AC covers the filtered case ("Empty state when filter yields no results") but not the zero-articles case. These may need different messages: "Noch keine Artikel vorhanden" vs. "Keine Artikel in dieser Kategorie." - **"Logged-in display name shown" (US-RES-003)**: The nav bar AC says "shows logged-in display name." The plan renders `{data.displayName}` in the nav. This satisfies the story. But the story also says family members can "see who has reserved an article." The display name is shown on the card — this is correct. No gap. - **The issue size is marked M**: Given the number of components, accessibility requirements, SSR data loading, and SQL join, M is accurate for the happy path. Add testing (currently absent from the plan) and the size becomes L. ### Recommendations - Add an explicit AC: "When zero articles exist in the database, a centered message reads 'Noch keine Artikel vorhanden.'" to distinguish from the filtered-empty state. - Add a measurable threshold to NFR-PERF-001 for this task: "The galerie page HTML response time must be under 500ms for up to 500 articles on the test server." Without a number, the NFR cannot be verified. - Add a guard in the `+page.svelte` template for `reserviertVon` being null even though the schema prevents it: ```svelte <div class="text-[10px] font-bold text-status-taken">● {artikel.reserviertVon ?? 'Unbekannt'}</div> ``` This prevents a `● null` from ever reaching the DOM. - The dependency on Task 7 (modal) should be tracked explicitly: either add "Depends on: #7" to this issue once Task 7 is created, or scope Task 6 to not include the `selectedId` state and modal placeholder until Task 7 is ready to land simultaneously. ### Open Decisions - **Empty state messaging**: Two distinct empty states exist (zero articles total vs. zero articles for category filter). The spec does not distinguish between them. Decide on one message per state before implementation to avoid inconsistent German copy between tasks. - **NFR-PERF-001 measurable threshold**: "Flüssig scrollen" is not a testable requirement. Agree on a response time target (e.g., "page load under 500ms for 500 articles on a mid-range Android phone on WiFi") so it can be verified before launch.
Author
Owner

🗳️ Decision Queue — Action Required

6 decisions need your input before implementation starts.


Architecture

  • Prepared statement location — The implementation plan defines SQL queries inline inside load functions in route files. The architecture spec and architect persona require all prepared statements to be defined at module-load time in lib/db.ts and imported. Inline is simpler to read locally; centralized in db.ts is consistent with the stated architecture and avoids re-parsing on every request. Choose one pattern and apply it everywhere. (Raised by: Markus Keller)

Deployment & Build

  • Tailwind version — The plan specifies "Tailwind CSS 3" and includes a v3-format tailwind.config.js. Running npm install tailwindcss today installs v4, which has an incompatible configuration format and would silently break all gallery classes. Options: (A) pin tailwindcss@^3 in package.json, or (B) rewrite the config in Tailwind v4 format (CSS-native, no tailwind.config.js). Must be decided before Task 1 runs. (Raised by: Tobias Wendt)

Accessibility & Content

  • Card thumbnail alt text — The plan uses alt="" (decorative) on card thumbnail images. These thumbnails are the primary visual differentiator for each article, so empty alt leaves screen reader users without meaningful content. Options: (A) make article title required in the data model so alt="{artikel.titel}" always resolves, (B) use a generated fallback like alt="Foto von Artikel in Kategorie {artikel.kategorie}", or (C) keep alt="" and accept the accessibility tradeoff for this short-lived app. Affects the artikel table schema and admin form UX. (Raised by: Leonie Voss)

Requirements & UX Copy

  • Empty state messaging — Two distinct empty states exist but only one is specified: (1) zero articles in the database at all, and (2) zero articles matching the active category filter. The current AC covers only case 2 ("Keine Artikel in dieser Kategorie"). Case 1 needs a different message ("Noch keine Artikel vorhanden") or the same message must serve both. Decide the copy for each state before implementation to avoid inconsistent German text across tasks. (Raised by: Elicit)

  • NFR-PERF-001 measurable threshold — "Flüssig scrollen" (smooth scroll) is not a testable requirement. There is no defined pass/fail criterion for the 500-article gallery. Options: (A) add a concrete threshold (e.g., HTML response under 500ms on the production server for 500 articles), or (B) accept SSR + no client fetch as a sufficient implementation proxy and skip measurement. If B is chosen, the NFR cannot be verified at launch. (Raised by: Elicit)

Testing

  • Test isolation pattern for galerie route — The load function calls getDb() at module level. Testing it directly requires either (A) using the existing _resetForTesting() export + process.env.DATABASE_PATH = ':memory:' pattern (already established in Tasks 2–4, consistent but requires careful import ordering), or (B) injecting db as a parameter to load (more explicit, but changes the function signature away from SvelteKit's expected shape). Option A is recommended for consistency — but this must be spelled out explicitly in the Task 6 test steps. (Raised by: Sara Holt)
## 🗳️ Decision Queue — Action Required _6 decisions need your input before implementation starts._ --- ### Architecture - **Prepared statement location** — The implementation plan defines SQL queries inline inside `load` functions in route files. The architecture spec and architect persona require all prepared statements to be defined at module-load time in `lib/db.ts` and imported. Inline is simpler to read locally; centralized in `db.ts` is consistent with the stated architecture and avoids re-parsing on every request. Choose one pattern and apply it everywhere. _(Raised by: Markus Keller)_ ### Deployment & Build - **Tailwind version** — The plan specifies "Tailwind CSS 3" and includes a v3-format `tailwind.config.js`. Running `npm install tailwindcss` today installs v4, which has an incompatible configuration format and would silently break all gallery classes. Options: (A) pin `tailwindcss@^3` in `package.json`, or (B) rewrite the config in Tailwind v4 format (CSS-native, no `tailwind.config.js`). Must be decided before Task 1 runs. _(Raised by: Tobias Wendt)_ ### Accessibility & Content - **Card thumbnail alt text** — The plan uses `alt=""` (decorative) on card thumbnail images. These thumbnails are the primary visual differentiator for each article, so empty alt leaves screen reader users without meaningful content. Options: (A) make article title required in the data model so `alt="{artikel.titel}"` always resolves, (B) use a generated fallback like `alt="Foto von Artikel in Kategorie {artikel.kategorie}"`, or (C) keep `alt=""` and accept the accessibility tradeoff for this short-lived app. Affects the `artikel` table schema and admin form UX. _(Raised by: Leonie Voss)_ ### Requirements & UX Copy - **Empty state messaging** — Two distinct empty states exist but only one is specified: (1) zero articles in the database at all, and (2) zero articles matching the active category filter. The current AC covers only case 2 ("Keine Artikel in dieser Kategorie"). Case 1 needs a different message ("Noch keine Artikel vorhanden") or the same message must serve both. Decide the copy for each state before implementation to avoid inconsistent German text across tasks. _(Raised by: Elicit)_ - **NFR-PERF-001 measurable threshold** — "Flüssig scrollen" (smooth scroll) is not a testable requirement. There is no defined pass/fail criterion for the 500-article gallery. Options: (A) add a concrete threshold (e.g., HTML response under 500ms on the production server for 500 articles), or (B) accept SSR + no client fetch as a sufficient implementation proxy and skip measurement. If B is chosen, the NFR cannot be verified at launch. _(Raised by: Elicit)_ ### Testing - **Test isolation pattern for galerie route** — The `load` function calls `getDb()` at module level. Testing it directly requires either (A) using the existing `_resetForTesting()` export + `process.env.DATABASE_PATH = ':memory:'` pattern (already established in Tasks 2–4, consistent but requires careful import ordering), or (B) injecting `db` as a parameter to `load` (more explicit, but changes the function signature away from SvelteKit's expected shape). Option A is recommended for consistency — but this must be spelled out explicitly in the Task 6 test steps. _(Raised by: Sara Holt)_
Sign in to join this conversation.