The SvelteKit prerender crawler cannot reach this route because
hooks.server.ts redirects all non-public paths to /login before the
crawler follows links. Explicitly listing the route in kit.prerender.entries
tells SvelteKit to render it directly without crawling.
Also removes a misleading comment that claimed the auth hook guards
prerendered static files — it does not. Prerendered HTML is served as a
static file by the reverse proxy; hooks.server.ts only runs for SSR requests.
Closes#472
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Felix and Elicit both flagged that the isReader formula had no
in-code explanation at the point of definition; future maintainers
adding a new permission level need a fast pointer to the architectural
rationale.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#007596 with white initials hits ~4.5:1 — at the AA threshold for
small text. #005F74 lifts it comfortably above 5:1, matching the
contrast margin of the other four palette entries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the top-persons fetch returns an empty list (or fails and
degrades to []), the chip area used to render the heading and the
view-all link with nothing in between, looking like a load failure.
Adds dashboard_reader_no_persons (de/en/es) and renders it above the
chip row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
WCAG 2.2 §2.5.8 (Target Size, Minimum). The Alle Personen → and Alle
Geschichten → text links were inline elements with no enforced minimum
height — small tap targets on mobile. inline-flex + min-h-[44px] keeps
the visual layout while guaranteeing the 44px hit area.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
text-ink-3 on bg-ink-3/10 (low-saturation grey on lighter grey) gave
roughly 2.8:1 contrast — below the 4.5:1 AA threshold for normal-weight
small text. Switching the foreground to text-ink-1 keeps the muted
background but lifts the text contrast well above 7:1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both view-all links (Alle Personen → in ReaderPersonChips, Alle
Geschichten → in ReaderRecentStories) were missing the
focus-visible:ring-2 ring used by every other interactive element on
the reader dashboard, leaving keyboard users with no visible focus
indicator. WCAG 2.1 §2.4.7 (Focus Visible, Level AA).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a readerData fixture and five render-level assertions: the three
ReaderStatsStrip totals, the recent-docs heading, the absent
contributor mission caption, and the drafts module appearing only when
canBlogWrite is true.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ISO strings differing only in millisecond precision or timezone
formatting represent the same instant but failed string equality, so
freshly created documents could miss the "Neu" badge depending on
whatever shape the backend serializer emitted.
Browser specs cannot run in the worktree (birpc WebSocket closure
crash documented in the PR description); the new vitest-browser test
must be verified from a normal checkout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors what npm run generate:api would emit against the StatsDTO
record (all three @Schema(REQUIRED) annotations). Round-1 fix only
updated totalStories; this brings the other two into line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- page.server.spec.ts: new test verifies topPersons=[] when that fetch
rejects, rest of reader data still loads — addresses @Sara concern
- ReaderPersonChips: replaces hardcoded "Dok." with
dashboard_reader_doc_count_suffix Paraglide key (de/en/es)
— addresses @Felix suggestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
brand-mint on white is ~2.8:1; brand-navy is ~10:1. Both "Alle Personen"
(ReaderPersonChips) and "Alle Geschichten" (ReaderRecentStories) links
updated: text-brand-navy underline hover:text-brand-mint.
Addresses @Leonie critical review finding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Collapses 5x duplicated null-check pattern in the reader fetch branch into
a single typed helper — addresses @Felix review blocker.
Also adds isReader/incompleteDocs/incompleteTotal to page.svelte.spec.ts
baseData so it satisfies the discriminated PageData union introduced by this PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Manually adds totalStories to generated StatsDTO type and wires it from
readerStats into ReaderStatsStrip — resolves @Elicit: stories tile was
permanently showing "—".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 5 new components for the permission-gated reader layout:
- ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages
- ReaderPersonChips: top-N persons by doc count with avatar + name
- ReaderDraftsModule: blog draft list for BLOG_WRITE users
- ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge
- ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt
Each component ships with a vitest-browser spec covering the key assertions.
Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person
boundary rule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Read-only users (no WRITE_ALL or ANNOTATE_ALL) now receive lean reader
data (stats, top-4 persons, 5 recent docs, 3 recent stories, and drafts
when BLOG_WRITE) instead of the contributor transcription queues.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function has a single in-source call site (TimelineDensityFilter)
but is exported so timeline.spec.ts can pin its boundary semantics
without rendering the orchestrator. Note that explicitly so future
readers don't treat the export as a public API contract.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defining --timeline-bar-idle / --timeline-bar-outside on :root from
inside a scoped <style> block leaks the contract into the global
namespace via component-local CSS, even though the selector itself
makes it work. Move both variables to layout.css next to the other
--palette / --c-* design tokens; the component <style> now only
consumes them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flat "{count} Dokumente / documents / documentos" keys read as
"1 Dokumente" / "1 documents" / "1 documentos" to a screen reader
when only one document falls in the month bucket. Splits each
locale into _singular + _plural keys and picks the form by count
in TimelineBars, mirroring the existing upload_banner_singular /
_plural pattern in this project.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bar buttons rendered with bg-transparent + p-0 fell back to the
default browser outline, which is invisible against bg-surface for
keyboard users. Adds the project-standard focus ring
(ring-2/brand-navy/offset-2) so the focused bar reads as focused.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WCAG 2.5.8 (target size, AA) requires 44×44 minimum, and the
project's senior persona makes that a hard floor on desktop too.
Reset-zoom: h-6 → h-11 + min-w-[44px] + px-3.
Clear-selection: h-6 w-6 → h-11 w-11.
Two regression tests on the TimelineDensityFilter spec assert the
sized classes so a future shrink can't slip through silently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pointerdown attaches three document-level listeners. Without an
explicit teardown, an unmount mid-drag (route change, view toggle,
viewport drops below lg) left them attached and they kept writing
to torn-down state cells.
Wrap the cleanup in $effect's return, which Svelte 5 invokes on
unmount. The listener-removal regression test pins this so the bug
cannot come back silently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously a 5xx, network blip, or JSON parse error all collapsed
into the same silent "no buckets" rendering. The widget still
degrades gracefully — failure should not block the document list —
but operators and Sentry now see the failure in browser devtools
instead of having to reverse-engineer a missing chart.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the reset-zoom and clear buttons out of the orchestrator into
their own component. Closes part 3 (final) of Felix's component-split
concern. Orchestrator now composes four single-purpose children
(TimelineBars, TimelineYAxis, TimelineXAxis, TimelineControls) and
keeps only the pointer choreography that links them.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Felix's review named "TimelineAxes" as one of four split targets.
The Y-axis and X-axis don't sit adjacent in the DOM — Y is a flex
sibling of the bars+X column — so two single-purpose components
beats a discriminator-prop component. tickIndicesFor and the
omitTickYear derivation move to TimelineXAxis where they belong.
Closes part 2 of Felix's component-split concern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the bar row + drag-window overlay + bar styling out of the
377-line orchestrator into a single-purpose component. The pointer
choreography (handle{PointerDown,DocumentMove,DocumentUp},
indexFromClientX, cleanupDragListeners) stays in the orchestrator
per Felix's note. Closes part 1 of Felix's component-split concern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
triggerSearch(zoomOverride?) made the call site read "depends on
whether the source event happened to include zoomFrom/zoomTo". Splits
into triggerSearchKeepZoom() and triggerSearchWithZoom(from, to) so
the contract is explicit at every call site. Closes Felix's review
nit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tablet (640–1024px) is exactly the iPad audience for transcribers.
At 240 monthly bars on an 800px column the bars fall to ~3.3px wide,
well below the 44×44 touch-target floor. Bumps the visibility class
from hidden sm:block to hidden lg:block and matches the page.ts
matchMedia gate to (min-width: 1024px). Closes Leonie's tablet
touch-target finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous #0d3358 measured 1.44:1 against the dark surface (#011526),
failing WCAG 1.4.11 (Non-text Contrast) for large UI elements.
#3a6e8c clears 3:1 with 3.33:1 while staying in the navy palette.
Closes Leonie's dark-mode contrast finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Disables the .bar-fill background-color transition for users who set
prefers-reduced-motion: reduce. Closes Leonie's vestibular-comfort
finding for users running the timeline alongside the live drag
cursor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a visually-hidden polite live region whose text reflects the
current drag range using the existing timeline_dragging_aria_live
i18n key. Closes Leonie's WCAG follow-the-drag-preview gap and turns
the previously orphaned i18n key into used markup.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
text-[10px] failed Leonie's 12px font floor. Bumps Y-axis labels and
the X-axis tick row to text-xs (12px); the X-axis row grows to h-4 to
accommodate the line height. Regression-pinned via two new specs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the raw "1915-08 · 5" aria-label, which a screen reader
announces as "1915 dash 08 middle dot 5", with the i18n template
timeline_bar_aria("{when}, {count} ...") and a getLocale-formatted
month/year string. Closes Leonie's WCAG 1.3.1 / 4.1.2 finding and
Felix's localisation flag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The timeline_count_label, timeline_loading, timeline_filtered_count,
and timeline_zoom_in keys were never referenced from src/. Felix's
review flagged them as 15 dead strings to translate. Removed across
de/en/es; the timeline_dragging_aria_live key is kept and will be
wired up in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom
range selector and adds X/Y axis labels so the chart is readable.
Drag interaction
- Pointerdown on a bar attaches document-level pointermove/pointerup/
pointercancel listeners; pointermove maps clientX to a bar index via
the row's bounding rect, so the mint-bordered window expands smoothly
even when the cursor leaves the bar or the chart entirely.
- pointerup commits filter + zoom atomically. Same-bar release on a
year bar (year-aggregated mode) zooms into that year's months;
same-bar release on a month bar emits filter-only.
- setPointerCapture removed — it was suppressing pointerenter on
sibling bars and preventing the drag window from expanding.
- Bar buttons are now h-full so the entire 80 px column is the hit
target, not just the visible bar height.
Axis labels
- Y-axis: max-count and 0 labels left of the bar area.
- X-axis: tickIndicesFor() picks decadal years for long ranges, evenly
spaced months for short year-zoom views, January boundaries for
multi-year month ranges. formatTickLabel() drops the year when the
visible range is a single year so 12-month zooms read "Jan Feb Mär…".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.
- New `clipBucketsToRange(buckets, from, to)` helper applied before the
>240-month year-aggregate decision, so a zoomed window flips back to
month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
props. Zoom button shown only when a selection exists and we aren't
already zoomed; reset-zoom shown only when zoomed. Both placed in a
shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
triggerSearch accepts an optional zoom override so the onzoomchange
callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The original AC required drag-to-select; the MVP shipped with click-only.
This adds pointer-driven range selection while preserving keyboard access:
- Pointer events (pointerdown / pointerenter / pointerup) drive the drag.
Pointer capture on pointerdown so the cursor leaving the bar still
produces drag-end events. Live preview class `in-drag-preview` highlights
the spanning bars while dragging; the URL/list refetch only fires on
pointerup (Felix R3).
- Click handler kept for keyboard activation (Enter/Space on focused bar).
A `suppressClick` flag prevents the synthesized click after a mouse
pointerup from double-emitting.
- Drag from later → earlier still emits ascending boundaries (drag direction
doesn't matter).
- Existing single-click keyboard selection unchanged.
4 new component tests cover the drag paths plus the live-preview class.
Existing 13 tests (single click, year mode, clear, visibility) still green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The +page.ts client-side load now forwards the active /documents URL
filters (q, senderId, receiverId, tag, tagQ, status, tagOp) to
/api/documents/density so the bars recompute when the user narrows the
search. Date bounds (from/to) are deliberately omitted — the chart is
the surface for picking those.
- New `DensityFilters` type and `buildDensityUrl(filters)` helper.
- `fetchDensity` accepts a filter snapshot (defaulting to {} for
back-compat in tests).
- 6 new unit tests cover URL building, multi-tag repetition, AND/OR
forwarding, the explicit-no-from/to invariant, and filter-aware fetch.
- Generated API types refreshed against the new backend signature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaced during proofshot: the production archive spans 1873 → 2023
(≈1809 month bars). With flex-1 + gap-px on a 1280 px container, every
pixel was consumed by gaps and bars rendered at 0 px width — visible as
"empty box, no bars".
Fix:
- Add aggregateToYears(buckets) that sums month counts per year and
returns YYYY-keyed entries.
- Add selectionBoundaryFrom/To that handle both YYYY and YYYY-MM labels
(Jan 1 → Dec 31 for years, first → last day for months).
- Component switches to year granularity when the gap-filled month
sequence exceeds 240 entries (~20 years), keeping each bar clickable.
- Drop the gap-px between bars and add min-w-px so sub-pixel rounding
still leaves something visible.
5 new tests cover aggregation, boundary helpers, and the component-level
year-mode + click behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit's PageData type generation only picks up +page.ts return values
when both files exist, so the runtime-merged server data was invisible to
TypeScript and svelte-check flagged every q/from/to/etc access in
+page.svelte. Spreading data into the +page.ts return restores the merge
at the type level. No runtime behaviour change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mounts the timeline above the result count, hidden on mobile via
\`hidden sm:block\` (defense-in-depth — +page.ts already gates the fetch).
The component's onchange callback updates local from/to and triggers
the existing search reload, so timeline selection composes with the
SearchFilterBar's other filters via AND semantics for free.
3 new page-level integration tests cover: widget renders when density
present, hides when null, and bar click navigates with correct
from/to URL params.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Density timeline widget: one bar per month within minDate/maxDate,
proportional heights, click-to-select-month with onchange callback,
and a clear button when a selection is active.
Notable details:
- Hidden entirely when density is null (mobile / calendar view; +page.ts
controls the gating).
- Zero-count months render at 2 px so the time axis stays readable
(Leonie's design intent overrides AC's literal "no bar" wording).
- Component-scoped --timeline-bar-idle CSS var for the dim idle color
(light: mint-tinted rgba; dark: structural navy #0d3358 — meets
WCAG 1.4.11 3:1 against surface, unlike the spec's #0E2535).
- Clear button is a real <button> with aria-label per Nora's a11y note.
- Bars are <button>s with aria-pressed selection state.
- Drag-range, tooltip, and year-tick labels are deferred for follow-ups —
the AC-required behaviours (click filter, clear, AND-with-other-filters)
are all in.
11 vitest-browser tests cover visibility gating, bar rendering with
gap-fill, zero-height floor, and selection/clear callback paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>