PasswordChangeForm: tests the null/success/error/mismatch banner branches
plus the form action wiring.
FileSectionNew: tests the no-file/file-selected toggle, onfileParsed
callback invocation with the parsed metadata, the early-return when no
file is in the change event, and the suggestedTitle fallback path.
Eleven tests across two files. Both follow the UploadZone template (props,
File API synthetic input, vi.fn() callback spies).
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes scaffolding pages from initial Paraglide setup that were never
navigated to in production. Shrinks the measured coverage surface and
removes dead code from the production bundle. CLAUDE.md route tables
updated to drop the demo/ entry.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract isPureTextRelevance() private static method to replace the
7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
+ findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
API dispatch, matching the new api.GET call site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three root causes prevented filters from reflecting the URL after SvelteKit
client-side navigation:
1. +page.server.ts now resolves sender/receiver display names in parallel with
the document search (UUID validation + silent 404 drop), so initialSenderName
/ initialReceiverName land in server data ready for the UI to use.
2. +page.svelte passes initialSenderName, initialReceiverName, and navKey
(incremented via untrack on every navigation) down to SearchFilterBar.
The untrack() prevents the effect from re-running due to its own navKey write.
3. SearchFilterBar forwards navKey as resetKey to each PersonTypeahead, which
already had a void resetKey guard added in the previous commit.
Together these ensure that after navigating to /documents?senderId=<uuid> the
typeahead shows the person's display name, and clicking × reset clears it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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 +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>
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>
The density data is fetched only on tablet/desktop (sm:+ breakpoint) and
when ?view=calendar is not set — mobile users and the future calendar view
(#386) skip the request entirely. Lives in +page.ts (client-side) so the
matchMedia gate can run in the browser; +page.server.ts continues to handle
the document search.
Non-ok responses and network failures degrade to an empty bucket list
rather than throwing, so the document list keeps rendering.
5 unit tests cover the gating + graceful degradation paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Playwright CDP click latency occasionally pushed past vi.waitFor's 1000ms
deadline, making the "opens a confirm dialog" test flaky. Switched to
btn.dispatchEvent(new MouseEvent(...)) — the same synchronous in-browser pattern
already used in GeschichteEditor.svelte.spec.ts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fixes all remaining failing tests in the browser project. Root cause in
every case: Playwright CDP-based clicks/keyboard events do not reliably
trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout:
- Buttons / result items: native `.element().click()` or
`dispatchEvent(new MouseEvent('click', { bubbles: true }))`
- Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))`
on the target DOM element
- TipTap selection: `element.focus()` + Selection API +
`document.dispatchEvent(new Event('selectionchange'))`
- ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))`
Also fixes pre-existing content/logic issues found during analysis:
- ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings
and wrong ARIA role (combobox not textbox)
- RichtlinienRuleCard: beide beispielInput + beispielOutput required for
arrow to render; querySelectorAll to get last code element
- admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode
heading selector; per-call mockResolvedValueOnce for dual-card page
- DocumentList: add total prop + result count paragraph (test relied on it)
- PersonTypeahead keyboard navigation: pressKey() helper with native
KeyboardEvent dispatch replaces userEvent.keyboard()
- PersonMultiSelect: native element clicks for result selection and
chip removal; keydown dispatch on result div for Enter key test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TranscriptionEditView: fix 4 failing tests:
- textarea → [role="textbox"] selector (editor is contenteditable, not <textarea>)
- button clicks → dispatchEvent(MouseEvent) for reliable Svelte 5 onclick with TipTap
- mentionedPersons test: init block with @mention token so deserialize() creates a
mention node; use userEvent.type + vi.waitFor (real timers) instead of fill +
fake timers, which prevents TipTap onUpdate from firing the debounce timer
EntityNavSection: anchor link click → add capture-phase preventDefault before
clicking to stop iframe navigation while allowing Svelte onclick handler to run
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three distinct root causes:
1. hilfe/transkription: Wikipedia link test was checking .textContent but
the accessible text had moved to aria-label in a prior commit.
2. documents/[id]/edit: vi.spyOn on a Svelte 5 compiled .svelte.ts service
object does not reliably track calls in vitest-browser mode; replaced
with a plain closure-based mock.
3. GeschichteEditor: TipTap's onMount steals focus and its ProseMirror
view interferes with Playwright CDP event dispatch. Three workarounds:
- blur: dispatchEvent(new FocusEvent('blur')) bypasses focus-state check
- save buttons: dispatchEvent(new MouseEvent('click')) from in-browser JS
context reliably triggers Svelte 5 onclick vs. Playwright CDP click
- trailing-space fill: input.value + dispatchEvent('input') works where
userEvent.fill('value ') silently fails to update bind:value
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLEANUP-2 (#413): convert two actionable TODOs to issue-referenced stubs
- +layout.server.ts:29 → TODO(#453) for dedicated admin stats endpoint
- ChronikRow.svelte: TODO(#454) for commentPreview; keep SECURITY line
as standalone comment (XSS guard stays co-located with the risk)
CLEANUP-3 (#414): add one-line justification comments to both naming
violators — SecurityUtils and GlobalExceptionHandler are both justified
by framework convention; no rename needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MissionControlStrip is a document-processing pipeline visualiser — it
imports document-domain components (SegmentationColumn, TranscriptionColumn,
ReadyColumn) and belongs in the document domain. It was placed in
shared/dashboard, creating a shared → document coupling that the upcoming
boundaries rule would block.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Senior-author persona requires 44px minimum touch targets on every
interactive control. The /geschichten filter row had three pills
(All / chip / + Person wählen) at h-9 (36px), missing the rule that
the toolbar already follows. Bumped all three to h-11.
Test added in page.svelte.spec.ts asserts the className contains
h-11 on every pill variant.
Addresses Leonie's iteration-3 concern #6 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /geschichten list page now renders one removable chip per active
person filter and lets users add more via the existing typeahead. The
URL uses repeated ?personId= params (matching the documents tag
filter), which the regenerated API client passes straight through to
the backend's new array-bound endpoint. New translation keys cover the
chip remove aria-label, the AND hint shown while picking, and the
multi-person empty state.
Story-detail body now uses an explicit Tailwind block-element selector
ruleset instead of the `prose` plugin, so the body fills the full max-w-3xl
parent width — previously `prose` clamped to ~65ch inside an already narrow
page.
GeschichtenCard heading and the "+ Geschichte schreiben" link now use
text-ink-2 (#4b5563 = 7.6:1 on white, AAA-passable) instead of text-ink-3
or text-ink/60. Same fix on the "+ Geschichte anhängen" link in the
Document drawer column and on the Personen / Dokumente section headers
on the story detail page.
Closes Leonie's review B1, B2 and S4 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a module docstring at the top of extractText.ts spelling out that this
is text extraction, not XSS sanitisation, and that callers must rely on
safeHtml() (DOMPurify) for security. Adds a Vitest test block with classic
XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript:
href) asserting that no markup is re-emitted, even though the module is
explicitly not a sanitiser.
Updates the two callers (/geschichten index, GeschichtenCard) to import
from the new path. The collapse-whitespace pass also makes the regex
fallback's output saner for excerpt rendering.
Closes Nora's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both /admin/groups/new and /admin/groups/[id] now expose BLOG_WRITE in the
standard-permissions card so admins can grant Geschichten authoring through
the UI instead of running raw SQL. Adds Paraglide labels in de/en/es.
Closes Markus's review B1 on PR #382.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The /persons/[id] +page.server.ts now fetches geschichten in parallel with
the other endpoints. Each test in this spec mocks the typed-client's GET
call sequentially, so each chain needs one extra resolved value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>