One bar per filled bucket, singular vs plural aria-label, aria-pressed
matrix, drag-window visibility tied to isDragging, onbarclick callback,
minimum-height handling for zero-count buckets.
8 tests covering ~25 branches.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TimelineControls: empty render when neither flag is set, reset button
gated on isZoomed, clear button gated on hasSelection, both-on, both
callback wirings.
TimelineXAxis: empty filled → no ticks, populated → ticks render,
omit-year branch when all buckets share a year, show-year branch
across multiple years, length-4 bucket-string fallback.
11 tests across two timeline primitives.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sixteen tests covering the four-column drawer: details column always
renders, persons column branches (no-persons placeholder vs sender
vs receivers), receiver overflow + show-all toggle, tags column
branches (placeholder vs anchor list with /?tag href encoding),
geschichten column visibility (hidden by default, shown for
canBlogWrite, attach link gated on canBlogWrite + documentId, list
rendering, show-all overflow), inferred-relationship pill on the
single-receiver branch.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Third Phase 5 split. The desktop action buttons — transcribe,
transcribe-stop, edit link, download link — become their own component
with a focused props interface (documentId, canWrite, isPdf,
transcribeMode bindable, filePath, originalFilename, fileUrl).
TDD: 8 tests covering empty render, transcribe button gating
(canWrite × isPdf × transcribeMode), stop-transcribe rendering, edit
link with documentId href, download link with filePath gating, all
hidden when in transcribe mode. After the test was red the component
was created.
DocumentTopBar dropped from 303 lines to 166. The orchestrator now
just composes BackButton, DocumentTopBarTitle, PersonChipRow,
OverflowPillButton, the details toggle, DocumentTopBarActions,
DocumentMobileMenu, and DocumentMetadataDrawer — each visual region
named in one or two words.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Second step of the Phase 5 split. The kebab dropdown — including
clickOutside handling and its own mobileMenuOpen state — becomes its
own component named after its visual region. The mobile snippet
duplication inside DocumentTopBar is removed; the component owns its
mobile-specific markup.
TDD: DocumentMobileMenu.svelte.test.ts (7 tests) was red first. The
component then made it green (kebab trigger, dropdown open/close on
click, transcribe button gated on canWrite × isPdf × !transcribeMode,
download link gated on filePath). DocumentTopBar wraps the new
component in a md:hidden div so responsive behaviour is unchanged.
Existing 18-test DocumentTopBar suite still passes.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
First step of the Phase 5 split plan from issue #496. The 14-line title
+ date block becomes its own component named after the visual region.
TDD red/green: DocumentTopBarTitle.svelte.test.ts written first
(7 tests covering title, originalFilename fallback, empty-string
fallback, short-date rendering, no-date branch, title attribute
sourcing). After the test was red the component was created.
DocumentTopBar.svelte updated to use it; the existing 18-test suite
still passes.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eighteen tests covering the user-observable matrix without yet splitting
the component (Phase 5 of the plan): title vs originalFilename fallback,
short-date rendering and absence, transcribe-button gating
(canWrite × isPdf × transcribeMode), edit-link gating, download-link
gating on filePath, kebab-menu visibility on (canWrite & isPdf) || filePath,
details drawer toggle, mobile menu open/close.
The 83 raw branches in the source map mostly to combinations of the
above flags — each test isolates one branch. Per Sara's guidance the
test names read as sentences and verify what the user sees, not internal
state.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DocumentViewer: loading / error / no-scan / image rendering branches.
filePath conditionally drives the direct-download link in the error
state; fileUrl + non-PDF contentType drives the <img> render.
PersonalInfoForm: default render, prop hydration including the German
date conversion path, success/error banner branches, form action wiring.
profile/+page: notification-checkbox enabled/disabled depending on
hasEmail, no-email hint visibility, prefsSuccess/prefsError banners,
fallback when notificationPrefs is null.
20 tests across three files.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DocumentStatusChip.svelte.test.ts asserting one branch per
DocumentStatus value (PLACEHOLDER, UPLOADED, TRANSCRIBED, REVIEWED,
ARCHIVED) plus the title/aria-label exposure. Each test queries the
element via getByTitle so the component's accessibility surface is
verified at the same time as its branch logic.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UploadZone is the canonical browser-test template referenced from issue #496
implementation guidance. Adding afterEach(cleanup) makes it match the
TranscriptionPanelHeader pattern and prevents cross-test DOM leakage as more
tests are added in this branch.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per Felix's review on issue #496, tests should query observable behaviour via
ARIA roles, not test-only data-testid attributes. Replaces every
'document.querySelector([data-testid=...])' with 'page.getByRole(...)'.
The disabled-button click test uses force: true so Playwright bypasses its
enabled-check — the behaviour under test is precisely that the click is
ignored.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add min-h-[44px] min-w-[44px] to all five PDF viewer buttons (prev,
next, zoom in, zoom out, annotation toggle) and widen icon-only
padding from p-1 to p-2. Adds aria-pressed to the annotation toggle
for correct toggle semantics (WCAG 2.2 §2.5.8 + ARIA 1.2).
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>
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>
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>
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>
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>
Pure utilities backing the TimelineDensityFilter component:
- monthBoundaryFrom/To convert YYYY-MM into LocalDate strings the existing
/api/documents/search accepts (first/last day of the month).
- buildMonthSequence enumerates months between minDate and maxDate, crossing
year boundaries.
- fillDensityGaps merges sparse backend buckets with the full month sequence,
producing zero-count entries for months that the API omitted.
14 unit tests cover leap years, year boundaries, null inputs, and out-of-order
buckets.
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>
statusLabel() was a one-line alias for formatDocumentStatus() with no
additional behaviour. Remove it and update DocumentStatusChip.svelte to
call formatDocumentStatus() directly. Remove the corresponding alias
test suite from the spec file.
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>
FieldLabelBadge is a generic UI primitive (additive/replace badge used in form
field labels). It lived in the document domain but was already imported by
PersonTypeahead (person domain), creating a person → document coupling.
Moving it to shared/primitives eliminates that cross-domain dependency.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These functions describe DocumentStatus display logic (dot colours, readable
labels) and belong in the document domain. They were incorrectly placed in
personFormat.ts. Moving them to documentStatusLabel.ts removes the
person → document dependency and prepares the codebase for the
boundaries/dependencies ESLint rule.
Refs #410
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>