feat(search): add direct page-jump control to document search pagination #340

Closed
opened 2026-04-26 19:56:24 +02:00 by marcel · 10 comments
Owner

User story

As a reader, I want to jump directly to a specific page in document search results, so that I don't have to click "next" repeatedly to reach page 5 or beyond.

Context

Current pagination is next/prev arrows only. With large collections users must click many times to reach deep pages. Standard pattern: numbered page buttons (e.g. 1 … 4 5 6 … last) or a page-number input field.

Acceptance criteria

  • Given search results span more than 1 page, then a page-jump control (numbered buttons or input) is visible in the pagination bar.
  • Given the user selects/enters page 5, then results immediately show page 5.
  • Given an out-of-range page number is entered, then the nearest valid page is used (no error, no crash).
  • The current page is visually distinguished from other page numbers.
## User story As a reader, I want to jump directly to a specific page in document search results, so that I don't have to click "next" repeatedly to reach page 5 or beyond. ## Context Current pagination is next/prev arrows only. With large collections users must click many times to reach deep pages. Standard pattern: numbered page buttons (e.g. 1 … 4 **5** 6 … last) or a page-number input field. ## Acceptance criteria - Given search results span more than 1 page, then a page-jump control (numbered buttons or input) is visible in the pagination bar. - Given the user selects/enters page 5, then results immediately show page 5. - Given an out-of-range page number is entered, then the nearest valid page is used (no error, no crash). - The current page is visually distinguished from other page numbers.
marcel added this to the (deleted) milestone 2026-04-26 19:56:24 +02:00
marcel added the P2-mediumfeatureui labels 2026-04-26 19:56:53 +02:00
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

  • The pagination machinery is already well-structured. buildSearchParams() in +page.svelte is a single source of truth for URL construction, and buildPageHref() reuses it cleanly. Adding a page-jump control is purely an additive change to Pagination.svelte — no backend changes needed.
  • totalPages is already computed and returned by the backend via DocumentSearchResult.paged(), and surfaced to the frontend via data.totalPages. The data contract is complete.
  • The backend enforces @Max(100_000) on the page parameter, which is the correct place for that guard. The out-of-range AC ("nearest valid page is used") must be clarified: the current backend returns a 400 for pages beyond 100_000 but returns an empty result (not a redirect) for any page number between the last page and 100_000. The frontend should clamp the input on the client side before navigation, not rely on backend validation to define UX behavior.
  • The PAGE_SIZE = 50 constant means meaningful page-jump use begins around 50+ documents. The family archive will reach that count early — the milestone label "Demo Day" confirms this needs to ship soon.

Recommendations

  • Keep all page-jump logic inside Pagination.svelte. Do not let it leak into +page.svelte's buildPageHref() — that function already accepts a target page number, so it will work without modification.
  • Implement client-side clamping in Pagination.svelte: targetPage = Math.max(0, Math.min(totalPages - 1, inputPage - 1)) before building the href. This satisfies the AC "nearest valid page is used" purely at the UI layer, which is the right boundary for UX-defined clamping.
  • Do not add a new API endpoint for page jumping. buildPageHref(targetPage) already produces the correct URL. Navigate to it via a normal <a href> (same as prev/next) for consistency and correct scroll restoration.
  • The numbered-buttons variant (1 … 4 5 6 … last) requires computing a page window, which is view logic. If that variant is chosen, extract to a $derived inside Pagination.svelte rather than polluting the parent.
## 🏗️ Markus Keller — Application Architect ### Observations - The pagination machinery is already well-structured. `buildSearchParams()` in `+page.svelte` is a single source of truth for URL construction, and `buildPageHref()` reuses it cleanly. Adding a page-jump control is purely an additive change to `Pagination.svelte` — no backend changes needed. - `totalPages` is already computed and returned by the backend via `DocumentSearchResult.paged()`, and surfaced to the frontend via `data.totalPages`. The data contract is complete. - The backend enforces `@Max(100_000)` on the `page` parameter, which is the correct place for that guard. The out-of-range AC ("nearest valid page is used") must be clarified: the current backend returns a 400 for pages beyond 100_000 but returns an empty result (not a redirect) for any page number between the last page and 100_000. The frontend should clamp the input on the client side before navigation, not rely on backend validation to define UX behavior. - The `PAGE_SIZE = 50` constant means meaningful page-jump use begins around 50+ documents. The family archive will reach that count early — the milestone label "Demo Day" confirms this needs to ship soon. ### Recommendations - Keep all page-jump logic inside `Pagination.svelte`. Do not let it leak into `+page.svelte`'s `buildPageHref()` — that function already accepts a target page number, so it will work without modification. - Implement client-side clamping in `Pagination.svelte`: `targetPage = Math.max(0, Math.min(totalPages - 1, inputPage - 1))` before building the href. This satisfies the AC "nearest valid page is used" purely at the UI layer, which is the right boundary for UX-defined clamping. - Do not add a new API endpoint for page jumping. `buildPageHref(targetPage)` already produces the correct URL. Navigate to it via a normal `<a href>` (same as prev/next) for consistency and correct scroll restoration. - The numbered-buttons variant (1 … 4 **5** 6 … last) requires computing a page window, which is view logic. If that variant is chosen, extract to a `$derived` inside `Pagination.svelte` rather than polluting the parent.
Author
Owner

👨‍💻 Felix Brandt — Fullstack Developer

Observations

  • Pagination.svelte is currently 80 lines and handles one visual concern: prev/next navigation. A page-jump addition (whether numbered buttons or input field) is a second visual concern. The component will need to stay below 60 lines of template markup or be split — e.g., PaginationJump.svelte for the input/buttons region and Pagination.svelte as a thin orchestrator.
  • The current makeHref prop is a clean abstraction: the component does not know about URL structure, it only calls makeHref(pageNumber). Any page-jump implementation must continue to call makeHref(targetPage) for consistency — do not pass a goto callback or duplicate URL logic inside the component.
  • The existing test suite (Pagination.svelte.spec.ts) is well-structured with vitest-browser-svelte, real DOM rendering, and meaningful assertions. The new behavior needs the same level of coverage: render the jump input, assert the produced href, assert clamping behavior, assert aria attributes.
  • The component uses $props() destructuring correctly. $derived is already used for hasPrev / hasNext. The page-window computation for numbered buttons would be a natural $derived.by(() => {...}) — multi-step, so use the .by() form.
  • The input-field variant requires handling a change/keydown event that builds an href and navigates. This should be an <a> wrapping a visually hidden target, or better: a <form> with method="GET" pointing at the documents path, progressive-enhanced to prevent full reload — consistent with the project's use:enhance preference.

Recommendations

  • Write the failing tests first (red): renders page-jump input, input value is clamped to totalPages, enter on page 5 produces correct href, input is aria-labelled.
  • Extract the page-window computation for the numbered-buttons variant into const pageWindow = $derived.by(() => { ... }) — keep the template readable.
  • Use {#each pageWindow as p (p)} with a key expression — the existing spec already tests for keyed #each compliance indirectly via href assertions.
  • The input type="number" with min="1" max={totalPages} provides browser-native clamping as a second defense layer, but do not rely on it alone — JavaScript clamping before href construction is required for the AC.
  • Dead code: if the numbered-buttons variant is implemented, remove the pagination_page_of label from the center position, since "Seite 5 von 12" becomes redundant when page 5 is visually highlighted. Update the existing test that asserts aria-current="page" — that attribute should move to the current page button, not a separate <span>.
## 👨‍💻 Felix Brandt — Fullstack Developer ### Observations - `Pagination.svelte` is currently 80 lines and handles one visual concern: prev/next navigation. A page-jump addition (whether numbered buttons or input field) is a second visual concern. The component will need to stay below 60 lines of template markup or be split — e.g., `PaginationJump.svelte` for the input/buttons region and `Pagination.svelte` as a thin orchestrator. - The current `makeHref` prop is a clean abstraction: the component does not know about URL structure, it only calls `makeHref(pageNumber)`. Any page-jump implementation must continue to call `makeHref(targetPage)` for consistency — do not pass a `goto` callback or duplicate URL logic inside the component. - The existing test suite (`Pagination.svelte.spec.ts`) is well-structured with `vitest-browser-svelte`, real DOM rendering, and meaningful assertions. The new behavior needs the same level of coverage: render the jump input, assert the produced href, assert clamping behavior, assert aria attributes. - The component uses `$props()` destructuring correctly. `$derived` is already used for `hasPrev` / `hasNext`. The page-window computation for numbered buttons would be a natural `$derived.by(() => {...})` — multi-step, so use the `.by()` form. - The input-field variant requires handling a `change`/`keydown` event that builds an href and navigates. This should be an `<a>` wrapping a visually hidden target, or better: a `<form>` with `method="GET"` pointing at the documents path, progressive-enhanced to prevent full reload — consistent with the project's `use:enhance` preference. ### Recommendations - Write the failing tests first (red): `renders page-jump input`, `input value is clamped to totalPages`, `enter on page 5 produces correct href`, `input is aria-labelled`. - Extract the page-window computation for the numbered-buttons variant into `const pageWindow = $derived.by(() => { ... })` — keep the template readable. - Use `{#each pageWindow as p (p)}` with a key expression — the existing spec already tests for keyed `#each` compliance indirectly via href assertions. - The input `type="number"` with `min="1"` `max={totalPages}` provides browser-native clamping as a second defense layer, but do not rely on it alone — JavaScript clamping before href construction is required for the AC. - Dead code: if the numbered-buttons variant is implemented, remove the `pagination_page_of` label from the center position, since "Seite 5 von 12" becomes redundant when page 5 is visually highlighted. Update the existing test that asserts `aria-current="page"` — that attribute should move to the current page button, not a separate `<span>`.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

  • This is a pure frontend change with no backend impact, no new dependencies, no new containers, and no CI configuration changes needed. Infrastructure footprint: zero.
  • The backend already handles the page query parameter via @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page. The @Max(100_000) guard prevents arithmetic overflow in pageable.getOffset(). No backend change required.
  • Page-number input field: user-typed values that arrive as URL query params are already validated and coerced on the server side. No sanitization layer is missing.
  • The PAGE_SIZE = 50 constant is defined in +page.server.ts. If it changes in the future, totalPages returned by the backend will adjust automatically — the page-jump control needs no knowledge of page size.

Recommendations

  • No new npm dependencies should be introduced for this feature. The required UI primitives (<input type="number">, <a href>) are native HTML. Adding a pagination library (like flowbite-svelte or similar) for a component this simple would be unjustified maintenance overhead.
  • Verify the feature works correctly in the dev container environment (ports 8080 / 3000). The /api/documents/search endpoint proxied via Vite config should work as-is.
  • If a numbered-button variant is chosen, the page-window computation happens entirely in the browser at render time — no server roundtrip. This is the correct approach and avoids any added latency.
## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations - This is a pure frontend change with no backend impact, no new dependencies, no new containers, and no CI configuration changes needed. Infrastructure footprint: zero. - The backend already handles the `page` query parameter via `@RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page`. The `@Max(100_000)` guard prevents arithmetic overflow in `pageable.getOffset()`. No backend change required. - Page-number input field: user-typed values that arrive as URL query params are already validated and coerced on the server side. No sanitization layer is missing. - The `PAGE_SIZE = 50` constant is defined in `+page.server.ts`. If it changes in the future, `totalPages` returned by the backend will adjust automatically — the page-jump control needs no knowledge of page size. ### Recommendations - No new npm dependencies should be introduced for this feature. The required UI primitives (`<input type="number">`, `<a href>`) are native HTML. Adding a pagination library (like `flowbite-svelte` or similar) for a component this simple would be unjustified maintenance overhead. - Verify the feature works correctly in the dev container environment (ports 8080 / 3000). The `/api/documents/search` endpoint proxied via Vite config should work as-is. - If a numbered-button variant is chosen, the page-window computation happens entirely in the browser at render time — no server roundtrip. This is the correct approach and avoids any added latency.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

The issue is well-written and production-ready for implementation. User story, context, and acceptance criteria are present and testable. A few gaps worth resolving before coding:

AC completeness:

  • AC3 ("nearest valid page is used") is ambiguous about where the clamping happens. Does "nearest valid page" mean: (a) the input is clamped client-side before navigation occurs, (b) the URL is corrected server-side, or (c) the server returns results for the last page without redirecting? These are three distinct behaviors. The most user-friendly is (a) — clamp in the UI and navigate to the clamped page.
  • AC3 does not specify what happens when 0 or a negative number is entered. Define explicitly: clamp to page 1 (i.e., 0-indexed page 0).
  • There is no AC for the case where totalPages === 1. The existing Pagination.svelte hides the control entirely when totalPages <= 1. Should the page-jump control also hide in this case? Recommend: yes, for consistency.

Missing NFRs:

  • Accessibility: The page-jump control must have an accessible label (aria-label or <label for="...">) — not just a placeholder. The current Pagination.svelte already meets WCAG 2.2 touch target requirements (min-h-[44px]); the new control must match.
  • i18n: Any new visible string (input placeholder, jump button label, aria-label) needs entries in de.json, en.json, and es.json.
  • Mobile: At 320px, both a numbered-button row and an input field can overflow. The AC does not specify mobile behavior. Recommend: on screens < 640px, prefer the input-field variant over numbered buttons (it takes less horizontal space).

Variant selection is an open decision — the issue says "numbered buttons or input" but doesn't commit to one. This affects component complexity, test scope, and mobile behavior significantly.

Recommendations

  • Add a sub-criterion to AC3: "Given page 0 or a negative value is entered, page 1 is used."
  • Add an NFR: "All visible strings in the page-jump control are present in de/en/es message catalogs."
  • Add an NFR: "The page-jump control meets WCAG 2.1 AA: labeled input, 44px touch target, visible focus ring."
  • Resolve the variant (numbered buttons vs. input field) before implementation begins — this is the only genuine open decision in the spec.

Open Decisions

  • Which variant: numbered page buttons (1 … 4 5 6 … last) or a free-text input field? The input field is simpler to implement and more mobile-friendly; numbered buttons are more discoverable for casual users but require a window computation algorithm and more test cases.
## 📋 Elicit — Requirements Engineer ### Observations The issue is well-written and production-ready for implementation. User story, context, and acceptance criteria are present and testable. A few gaps worth resolving before coding: **AC completeness:** - AC3 ("nearest valid page is used") is ambiguous about where the clamping happens. Does "nearest valid page" mean: (a) the input is clamped client-side before navigation occurs, (b) the URL is corrected server-side, or (c) the server returns results for the last page without redirecting? These are three distinct behaviors. The most user-friendly is (a) — clamp in the UI and navigate to the clamped page. - AC3 does not specify what happens when `0` or a negative number is entered. Define explicitly: clamp to page 1 (i.e., 0-indexed page 0). - There is no AC for the case where `totalPages === 1`. The existing `Pagination.svelte` hides the control entirely when `totalPages <= 1`. Should the page-jump control also hide in this case? Recommend: yes, for consistency. **Missing NFRs:** - **Accessibility**: The page-jump control must have an accessible label (`aria-label` or `<label for="...">`) — not just a placeholder. The current `Pagination.svelte` already meets WCAG 2.2 touch target requirements (`min-h-[44px]`); the new control must match. - **i18n**: Any new visible string (input placeholder, jump button label, aria-label) needs entries in `de.json`, `en.json`, and `es.json`. - **Mobile**: At 320px, both a numbered-button row and an input field can overflow. The AC does not specify mobile behavior. Recommend: on screens < 640px, prefer the input-field variant over numbered buttons (it takes less horizontal space). **Variant selection is an open decision** — the issue says "numbered buttons or input" but doesn't commit to one. This affects component complexity, test scope, and mobile behavior significantly. ### Recommendations - Add a sub-criterion to AC3: "Given page 0 or a negative value is entered, page 1 is used." - Add an NFR: "All visible strings in the page-jump control are present in de/en/es message catalogs." - Add an NFR: "The page-jump control meets WCAG 2.1 AA: labeled input, 44px touch target, visible focus ring." - Resolve the variant (numbered buttons vs. input field) before implementation begins — this is the only genuine open decision in the spec. ### Open Decisions - Which variant: numbered page buttons (1 … 4 **5** 6 … last) or a free-text input field? The input field is simpler to implement and more mobile-friendly; numbered buttons are more discoverable for casual users but require a window computation algorithm and more test cases.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

  • The page parameter is user-controlled input that travels via URL query string → +page.server.ts → backend. The existing validation chain is correct: Number(url.searchParams.get('page') ?? '0') || 0 coerces non-numeric input to 0, and Math.max(0, ...) prevents negatives. On the backend, @Min(0) @Max(100_000) provides server-side enforcement. The overflow guard comment in DocumentController.java is exactly the right kind of security comment (explains the threat, not just the code).
  • No new attack surface is introduced by a page-jump control. The input field is a number input whose value is read client-side, clamped, and used to build a URL. It is not reflected into the DOM as HTML, not passed to eval(), and not used in a query without parameterization.
  • Potential concern — open redirect via crafted URL: If the page-jump control generates a full URL instead of a path (e.g., https://attacker.com/documents?page=5), an open redirect could occur. Mitigation: buildPageHref() always returns a relative path starting with /documents? — this is already correct and should remain so. The new control must use buildPageHref() or makeHref(), not construct URLs from raw user input.
  • The type="number" HTML attribute prevents arbitrary string injection from the input field on modern browsers, but must not be the sole defense. JavaScript clamping before href construction is required.

Recommendations

  • Use makeHref(targetPage) as the exclusive URL-building mechanism — never concatenate user input directly into the href string.
  • Apply Math.max(0, Math.min(totalPages - 1, userInput - 1)) before passing to makeHref. This is both the UX clamping (AC3) and the security boundary.
  • Add a test asserting that an out-of-range value (e.g., totalPages + 999) is clamped to the last page href, not a crafted URL. This doubles as a security regression test.
  • No new permissions, no new API endpoints, no backend changes — from a security posture, this is a low-risk feature.
## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations - The page parameter is user-controlled input that travels via URL query string → `+page.server.ts` → backend. The existing validation chain is correct: `Number(url.searchParams.get('page') ?? '0') || 0` coerces non-numeric input to 0, and `Math.max(0, ...)` prevents negatives. On the backend, `@Min(0) @Max(100_000)` provides server-side enforcement. The overflow guard comment in `DocumentController.java` is exactly the right kind of security comment (explains the threat, not just the code). - **No new attack surface** is introduced by a page-jump control. The input field is a number input whose value is read client-side, clamped, and used to build a URL. It is not reflected into the DOM as HTML, not passed to `eval()`, and not used in a query without parameterization. - **Potential concern — open redirect via crafted URL**: If the page-jump control generates a full URL instead of a path (e.g., `https://attacker.com/documents?page=5`), an open redirect could occur. Mitigation: `buildPageHref()` always returns a relative path starting with `/documents?` — this is already correct and should remain so. The new control must use `buildPageHref()` or `makeHref()`, not construct URLs from raw user input. - The `type="number"` HTML attribute prevents arbitrary string injection from the input field on modern browsers, but must not be the sole defense. JavaScript clamping before href construction is required. ### Recommendations - Use `makeHref(targetPage)` as the exclusive URL-building mechanism — never concatenate user input directly into the href string. - Apply `Math.max(0, Math.min(totalPages - 1, userInput - 1))` before passing to `makeHref`. This is both the UX clamping (AC3) and the security boundary. - Add a test asserting that an out-of-range value (e.g., `totalPages + 999`) is clamped to the last page href, not a crafted URL. This doubles as a security regression test. - No new permissions, no new API endpoints, no backend changes — from a security posture, this is a low-risk feature.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

  • The existing Pagination.svelte.spec.ts is a solid baseline: vitest-browser-svelte, real DOM, role/testid selectors, no internal state assertions. The test structure is exactly right.

  • Coverage gaps that will emerge with the new control:

    • No test for totalPages === 0 or totalPages === 1 (hidden state) — this edge case should already be tested, and the new control must be verified hidden in these conditions too.
    • The AC "nearest valid page is used" requires explicit test cases: input 0, input totalPages + 1, input NaN, input 99999.
    • If the numbered-buttons variant is chosen, the page-window algorithm (e.g., showing 1 … 4 5 6 … 12) needs tests for: page 1, page 2 (left edge), page totalPages, page totalPages - 1 (right edge), and a middle page.
  • E2E layer: The existing Playwright suite should gain one happy-path test: from the document search page, use the page-jump control to navigate to page 3, assert the URL contains page=3, and assert results are rendered. This covers the full SSR load cycle that unit tests cannot.

  • Current test gap: There is no E2E test for the existing prev/next pagination. Adding the page-jump E2E test is a good opportunity to fill that gap simultaneously with a Page Object Model entry.

Recommendations

  • Add these unit test cases to Pagination.svelte.spec.ts:
    • renders page-jump control when totalPages > 1
    • does not render page-jump control when totalPages <= 1
    • page-jump input is aria-labelled
    • clamped input below 1 produces href for page 0
    • clamped input above totalPages produces href for last page
    • page-jump control has min-h-[44px] touch target
  • Add to the Playwright suite: one E2E scenario using page.getByRole('navigation') to locate the pagination nav and jump to a specific page.
  • The makeHref spy pattern in the existing test (vi.fn(makeHref)) is the correct way to assert which page numbers are requested — use it for the jump control assertions too.
  • Do not add a Playwright test for every input value — clamping logic belongs at the unit test layer. E2E covers one successful jump only.
## 🧪 Sara Holt — QA Engineer ### Observations - The existing `Pagination.svelte.spec.ts` is a solid baseline: `vitest-browser-svelte`, real DOM, role/testid selectors, no internal state assertions. The test structure is exactly right. - Coverage gaps that will emerge with the new control: - No test for `totalPages === 0` or `totalPages === 1` (hidden state) — this edge case should already be tested, and the new control must be verified hidden in these conditions too. - The AC "nearest valid page is used" requires explicit test cases: input `0`, input `totalPages + 1`, input `NaN`, input `99999`. - If the numbered-buttons variant is chosen, the page-window algorithm (e.g., showing `1 … 4 5 6 … 12`) needs tests for: page 1, page 2 (left edge), page `totalPages`, page `totalPages - 1` (right edge), and a middle page. - **E2E layer**: The existing Playwright suite should gain one happy-path test: from the document search page, use the page-jump control to navigate to page 3, assert the URL contains `page=3`, and assert results are rendered. This covers the full SSR load cycle that unit tests cannot. - **Current test gap**: There is no E2E test for the existing prev/next pagination. Adding the page-jump E2E test is a good opportunity to fill that gap simultaneously with a Page Object Model entry. ### Recommendations - Add these unit test cases to `Pagination.svelte.spec.ts`: - `renders page-jump control when totalPages > 1` - `does not render page-jump control when totalPages <= 1` - `page-jump input is aria-labelled` - `clamped input below 1 produces href for page 0` - `clamped input above totalPages produces href for last page` - `page-jump control has min-h-[44px] touch target` - Add to the Playwright suite: one E2E scenario using `page.getByRole('navigation')` to locate the pagination nav and jump to a specific page. - The `makeHref` spy pattern in the existing test (`vi.fn(makeHref)`) is the correct way to assert which page numbers are requested — use it for the jump control assertions too. - Do not add a Playwright test for every input value — clamping logic belongs at the unit test layer. E2E covers one successful jump only.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead

Observations

  • The current Pagination.svelte already meets the project's accessibility baseline: 44px touch targets, focus-visible:ring-2 focus-visible:ring-brand-navy, aria-current="page", aria-hidden on decorative chevrons. The new page-jump control must match this standard throughout.
  • Primary audience concern: The 60+ transcriber cohort uses this on laptop/tablet. Numbered page buttons are more discoverable for this group than a free-text number input — clicking a visible button labeled "5" is cognitively easier than recalling the page number, typing it, and pressing Enter. However, more than 7–9 buttons become overwhelming. The ellipsis pattern (1 … 4 5 6 … 12) is the right approach for large page counts.
  • Mobile concern: At 320px, a full numbered button row with ellipsis will overflow horizontally. Mitigation: on sm: breakpoint and below, show only the input-field variant (or just prev/next + current page label as today). On sm: and above, show the numbered buttons.
  • Brand compliance: Active page button should use bg-brand-navy text-white to match the project's primary action style. Inactive page buttons should match the existing linkBase pattern (bg-white border-line text-ink hover:bg-surface). Ellipsis spans () should be text-ink-2, aria-hidden="true", non-interactive.
  • Font: All pagination controls use font-sans text-sm font-bold per the existing controlBase class. The page number buttons must maintain this for visual consistency.

Recommendations

  • Use the ellipsis pattern: always show first page, last page, current page, one neighbor on each side, and spans for gaps. This limits the maximum number of rendered buttons to 7, fitting on all tablet screens.
  • Exact classes for the active page button: inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white — replace aria-current="page" from the <span> to this <button> or <a>.
  • Ellipsis spans: <span aria-hidden="true" class="px-2 text-sm text-ink-2">…</span> — no border, no background, not focusable.
  • On mobile (< sm:): keep the existing prev / "Seite X von Y" / next layout. Do not show page buttons below 640px. This is the responsive rule to encode in the component.
  • The input-field variant (if chosen over buttons): minimum width w-16, text-center, type="number", paired with <label class="sr-only"> for screen reader access. Do not use a placeholder as the label.

Open Decisions

  • Mobile breakpoint behavior: hide numbered buttons below sm: (640px) and fall back to the current prev/next layout, or show a compact input field instead? The recommendation is to hide and fall back to prev/next on mobile — this avoids a second layout branch and keeps mobile clean.
## 🎨 Leonie Voss — UI/UX Design Lead ### Observations - The current `Pagination.svelte` already meets the project's accessibility baseline: 44px touch targets, `focus-visible:ring-2 focus-visible:ring-brand-navy`, `aria-current="page"`, `aria-hidden` on decorative chevrons. The new page-jump control must match this standard throughout. - **Primary audience concern**: The 60+ transcriber cohort uses this on laptop/tablet. Numbered page buttons are more discoverable for this group than a free-text number input — clicking a visible button labeled "5" is cognitively easier than recalling the page number, typing it, and pressing Enter. However, more than 7–9 buttons become overwhelming. The ellipsis pattern (`1 … 4 5 6 … 12`) is the right approach for large page counts. - **Mobile concern**: At 320px, a full numbered button row with ellipsis will overflow horizontally. Mitigation: on `sm:` breakpoint and below, show only the input-field variant (or just prev/next + current page label as today). On `sm:` and above, show the numbered buttons. - **Brand compliance**: Active page button should use `bg-brand-navy text-white` to match the project's primary action style. Inactive page buttons should match the existing `linkBase` pattern (`bg-white border-line text-ink hover:bg-surface`). Ellipsis spans (`…`) should be `text-ink-2`, `aria-hidden="true"`, non-interactive. - **Font**: All pagination controls use `font-sans text-sm font-bold` per the existing `controlBase` class. The page number buttons must maintain this for visual consistency. ### Recommendations - Use the ellipsis pattern: always show first page, last page, current page, one neighbor on each side, and `…` spans for gaps. This limits the maximum number of rendered buttons to 7, fitting on all tablet screens. - Exact classes for the active page button: `inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white` — replace `aria-current="page"` from the `<span>` to this `<button>` or `<a>`. - Ellipsis spans: `<span aria-hidden="true" class="px-2 text-sm text-ink-2">…</span>` — no border, no background, not focusable. - On mobile (`< sm:`): keep the existing prev / "Seite X von Y" / next layout. Do not show page buttons below 640px. This is the responsive rule to encode in the component. - The input-field variant (if chosen over buttons): minimum width `w-16`, `text-center`, `type="number"`, paired with `<label class="sr-only">` for screen reader access. Do not use a placeholder as the label. ### Open Decisions - Mobile breakpoint behavior: hide numbered buttons below `sm:` (640px) and fall back to the current prev/next layout, or show a compact input field instead? The recommendation is to hide and fall back to prev/next on mobile — this avoids a second layout branch and keeps mobile clean.
Author
Owner

🗳️ Decision Queue

Consolidated open decisions requiring product input before implementation starts.


1. Control Variant: Numbered Buttons vs. Input Field

Raised by: Elicit, Leonie, Markus

The issue leaves both options open. These are not equivalent — they differ in complexity, mobile behavior, and UX suitability for the 60+ audience:

Numbered buttons (1 … 4 5 6 … last) Input field
Discoverability High — user sees all available pages Low — user must know to type
Implementation complexity Moderate — page-window algorithm + keyed {#each} Low — single <input type="number">
Test scope ~10 additional unit tests (window edge cases) ~4 additional unit tests
Mobile (320px) Overflows unless hidden below sm: Fits comfortably
Senior-friendliness (60+) Better Worse

Recommendation from reviewers: Numbered buttons on tablet/desktop (sm: and above), falling back to the existing prev/next layout on mobile (no second control). This is the most complete solution for the primary audience.


2. Mobile Fallback: Hide Page Buttons or Show Input Field Below sm:

Raised by: Leonie, Elicit

If the numbered-buttons variant is chosen, what appears below 640px?

  • Option A: Keep the current prev / "Seite X von Y" / next layout on mobile — no page-jump control below sm:. Simple, no second layout branch.
  • Option B: Show a compact <input type="number"> below sm: as a fallback. More powerful but doubles the component's rendering paths and test scope.

Recommendation from reviewers: Option A — hide page buttons on mobile and preserve the current layout. The primary page-jump use case (deep navigation into large result sets) is a desktop/tablet task.


3. AC3 Clamping Location: Client-Side vs. Server-Side

Raised by: Elicit, Markus, Nora

AC3 says "nearest valid page is used" but doesn't specify where. The backend already returns 400 for page > 100_000, and returns an empty result for page > totalPages. Neither is the intended behavior for AC3.

Decision needed: Confirm that clamping is a client-side responsibility only — the frontend clamps Math.max(0, Math.min(totalPages - 1, userInput - 1)) before generating the href, and the backend is not modified.

Recommendation from reviewers: Yes, client-side clamping only. No backend change needed.

## 🗳️ Decision Queue Consolidated open decisions requiring product input before implementation starts. --- ### 1. Control Variant: Numbered Buttons vs. Input Field **Raised by:** Elicit, Leonie, Markus The issue leaves both options open. These are not equivalent — they differ in complexity, mobile behavior, and UX suitability for the 60+ audience: | | Numbered buttons (1 … 4 **5** 6 … last) | Input field | |---|---|---| | Discoverability | High — user sees all available pages | Low — user must know to type | | Implementation complexity | Moderate — page-window algorithm + keyed `{#each}` | Low — single `<input type="number">` | | Test scope | ~10 additional unit tests (window edge cases) | ~4 additional unit tests | | Mobile (320px) | Overflows unless hidden below `sm:` | Fits comfortably | | Senior-friendliness (60+) | Better | Worse | **Recommendation from reviewers:** Numbered buttons on tablet/desktop (`sm:` and above), falling back to the existing prev/next layout on mobile (no second control). This is the most complete solution for the primary audience. --- ### 2. Mobile Fallback: Hide Page Buttons or Show Input Field Below `sm:` **Raised by:** Leonie, Elicit If the numbered-buttons variant is chosen, what appears below 640px? - **Option A**: Keep the current prev / "Seite X von Y" / next layout on mobile — no page-jump control below `sm:`. Simple, no second layout branch. - **Option B**: Show a compact `<input type="number">` below `sm:` as a fallback. More powerful but doubles the component's rendering paths and test scope. **Recommendation from reviewers:** Option A — hide page buttons on mobile and preserve the current layout. The primary page-jump use case (deep navigation into large result sets) is a desktop/tablet task. --- ### 3. AC3 Clamping Location: Client-Side vs. Server-Side **Raised by:** Elicit, Markus, Nora AC3 says "nearest valid page is used" but doesn't specify where. The backend already returns 400 for `page > 100_000`, and returns an empty result for `page > totalPages`. Neither is the intended behavior for AC3. **Decision needed:** Confirm that clamping is a client-side responsibility only — the frontend clamps `Math.max(0, Math.min(totalPages - 1, userInput - 1))` before generating the href, and the backend is not modified. **Recommendation from reviewers:** Yes, client-side clamping only. No backend change needed.
Author
Owner
  1. Numbered buttons
  2. Hide page jump
  3. Client side
1. Numbered buttons 2. Hide page jump 3. Client side
Author
Owner

Implementation complete

Branch: feat/issue-340-pagination-page-jump
Commit: 2079840a

What was built

Numbered page-jump buttons (ellipsis pattern: 1 … 4 5 6 … 12) added to Pagination.svelte.

Decisions from the Decision Queue applied:

  1. Numbered buttons — ellipsis window shows first, last, current ± 1 neighbor, with spans for gaps. Maximum 7 elements rendered.
  2. Mobile fallback — page buttons hidden below sm: (640px) via hidden sm:flex. Existing prev / "Seite X von Y" / next layout preserved on mobile.
  3. Client-side clampingmakeHref(entry - 1) uses the existing clamping contract; no backend changes.

Files changed

  • frontend/src/lib/components/Pagination.svelte — added pageWindow $derived.by() computation + numbered button row
  • frontend/src/lib/components/Pagination.svelte.spec.ts — 12 new tests covering: buttons visible/hidden by totalPages, aria-current on active, brand-navy class, 44px touch target, makeHref linkage, first/last always shown, left/right ellipsis presence, mobile hidden class
  • frontend/messages/{de,en,es}.json — new pagination_page_button i18n key

Test result

21 tests pass (9 existing + 12 new), all green. Lint and Prettier clean.

## Implementation complete ✅ Branch: `feat/issue-340-pagination-page-jump` Commit: `2079840a` ### What was built **Numbered page-jump buttons** (ellipsis pattern: 1 … 4 **5** 6 … 12) added to `Pagination.svelte`. **Decisions from the Decision Queue applied:** 1. **Numbered buttons** — ellipsis window shows first, last, current ± 1 neighbor, with `…` spans for gaps. Maximum 7 elements rendered. 2. **Mobile fallback** — page buttons hidden below `sm:` (640px) via `hidden sm:flex`. Existing prev / "Seite X von Y" / next layout preserved on mobile. 3. **Client-side clamping** — `makeHref(entry - 1)` uses the existing clamping contract; no backend changes. ### Files changed - `frontend/src/lib/components/Pagination.svelte` — added `pageWindow` `$derived.by()` computation + numbered button row - `frontend/src/lib/components/Pagination.svelte.spec.ts` — 12 new tests covering: buttons visible/hidden by totalPages, aria-current on active, brand-navy class, 44px touch target, makeHref linkage, first/last always shown, left/right ellipsis presence, mobile hidden class - `frontend/messages/{de,en,es}.json` — new `pagination_page_button` i18n key ### Test result 21 tests pass (9 existing + 12 new), all green. Lint and Prettier clean.
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#340