From 696a86799d8113d72156343d9a534737b704a930 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:29:46 +0200 Subject: [PATCH 01/29] fix(a11y): add aria-modal to ConfirmDialog for older AT NVDA+Chrome <2022 and VoiceOver iOS <16 need explicit aria-modal="true"; showModal() implicit modal semantics are not enough for older AT. One-line patch benefits all dialog uses. Refs #781 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/shared/primitives/ConfirmDialog.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/shared/primitives/ConfirmDialog.svelte b/frontend/src/lib/shared/primitives/ConfirmDialog.svelte index 1fb1abd5..9e5d6d1a 100644 --- a/frontend/src/lib/shared/primitives/ConfirmDialog.svelte +++ b/frontend/src/lib/shared/primitives/ConfirmDialog.svelte @@ -19,6 +19,7 @@ $effect(() => { { e.preventDefault(); -- 2.49.1 From 36f7bdad45265d60e788079be9dda100b2d9160a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:30:56 +0200 Subject: [PATCH 02/29] test(document): fence RANGE end-date reveal before DatePrecisionField extraction Adds a RANGE-reveal regression test to WhoWhenSection's spec. The existing spec covered only date pre-fill / hideDate / location, leaving the end-date region without a red signal. This must stay green across the #781 extraction of DatePrecisionField into $lib/shared/primitives/. Refs #781 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/document/WhoWhenSection.svelte.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.spec.ts b/frontend/src/lib/document/WhoWhenSection.svelte.spec.ts index a1b26628..e38a0969 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.spec.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.spec.ts @@ -39,4 +39,17 @@ describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', ( const locationInput = document.querySelector('input#location') as HTMLInputElement; expect(locationInput.value).toBe('Berlin'); }); + + // Regression fence for the DatePrecisionField extraction (#781): the existing + // spec covered only date pre-fill / hideDate / location, so the RANGE end-date + // reveal had no red signal. This test must stay green across the extraction. + it('reveals the end-date field when precision is RANGE', async () => { + render(WhoWhenSection, { precision: 'RANGE' }); + await expect.element(page.getByLabelText('Enddatum')).toBeVisible(); + }); + + it('hides the end-date field when precision is not RANGE', async () => { + render(WhoWhenSection, { precision: 'YEAR' }); + await expect.element(page.getByTestId('who-when-end-date')).not.toBeInTheDocument(); + }); }); -- 2.49.1 From 62fcc53f5c322a37038b03592e51465edccfb162 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:33:50 +0200 Subject: [PATCH 03/29] refactor(shared): extract DatePrecisionField primitive from WhoWhenSection Pulls the date + precision + RANGE-end-date region into a generic primitive in $lib/shared/primitives/ so both document/ (WhoWhenSection) and timeline/ (EventForm, #781) can consume it without a cross-domain import. Preserves the aria-live="polite" outer wrapper, onMount one-time seeding, $bindable precision/endDateIso, the PRECISIONS array, and forwards data-testid attributes so the existing WhoWhenSection spec selectors survive. WhoWhenSection spec stays green (7/7). Refs #781 Co-Authored-By: Claude Opus 4.8 --- .../src/lib/document/WhoWhenSection.svelte | 150 ++------------ .../primitives/DatePrecisionField.svelte | 191 ++++++++++++++++++ 2 files changed, 208 insertions(+), 133 deletions(-) create mode 100644 frontend/src/lib/shared/primitives/DatePrecisionField.svelte diff --git a/frontend/src/lib/document/WhoWhenSection.svelte b/frontend/src/lib/document/WhoWhenSection.svelte index 4912f8d7..393705cd 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte +++ b/frontend/src/lib/document/WhoWhenSection.svelte @@ -1,9 +1,8 @@
@@ -104,79 +45,22 @@ $effect(() => {
{#if !hideDate} - -
- - - - {#if dateInvalid} -

{m.form_date_error()}

- {/if} -
- -
- - -
- - -
- {#if showEndDate} -
- - - {#if endBeforeStart} - -

- {m.error_invalid_date_range()} -

- {/if} -
- {/if} -
- + + {/if} diff --git a/frontend/src/lib/shared/primitives/DatePrecisionField.svelte b/frontend/src/lib/shared/primitives/DatePrecisionField.svelte new file mode 100644 index 00000000..36403ac4 --- /dev/null +++ b/frontend/src/lib/shared/primitives/DatePrecisionField.svelte @@ -0,0 +1,191 @@ + + + +
+ + + + {#if dateInvalid} +

+ {m.form_date_error()} +

+ {/if} +
+ + +
+ + +
+ + +
+ {#if showEndDate} +
+ + + {#if endBeforeStart} + +

+ {m.error_invalid_date_range()} +

+ {/if} +
+ {/if} +
+ + -- 2.49.1 From 0ed7fb4c0e9da4754cbf759ab1867538b7cde773 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:35:57 +0200 Subject: [PATCH 04/29] i18n(timeline): add event-editor keys (de/en/es) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Labels, section headings, type options (PERSONAL/HISTORICAL), picker empty states, required-field errors, delete-confirm and unsaved-changes copy for the curator event create/edit forms. No new ErrorCode introduced — the feature reuses existing TIMELINE_EVENT_* + CONFLICT codes from #3. Refs #781 Co-Authored-By: Claude Opus 4.8 --- frontend/messages/de.json | 25 +++++++++++++++++++++++++ frontend/messages/en.json | 25 +++++++++++++++++++++++++ frontend/messages/es.json | 25 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ec66eced..b4e6ba98 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1046,6 +1046,31 @@ "timeline_derived_birth": "Geburt", "timeline_derived_death": "Tod", "timeline_derived_marriage": "Heirat", + "event_editor_new_title": "Neues Ereignis", + "event_editor_edit_title": "Ereignis bearbeiten", + "event_editor_section_when": "Wann", + "event_editor_section_persons": "Beteiligte Personen", + "event_editor_section_documents": "Verknüpfte Briefe", + "event_editor_section_description": "Beschreibung", + "event_editor_title_label": "Titel", + "event_editor_title_placeholder": "Titel des Ereignisses", + "event_editor_title_required": "Bitte einen Titel eingeben.", + "event_editor_date_required": "Bitte ein Datum eingeben.", + "event_editor_type_label": "Typ", + "event_editor_persons_label": "Personen", + "event_editor_documents_label": "Briefe", + "event_editor_description_label": "Beschreibung", + "event_editor_description_placeholder": "Optionale Beschreibung", + "event_editor_persons_empty": "Noch keine Person verknüpft", + "event_editor_documents_empty": "Noch kein Dokument verknüpft", + "event_type_PERSONAL": "Persönlich", + "event_type_HISTORICAL": "Historisch", + "event_editor_save": "Speichern", + "event_editor_save_hint": "Ereignisse erscheinen im Zeitstrahl.", + "event_editor_delete": "Löschen", + "event_editor_delete_confirm_title": "Ereignis löschen?", + "event_editor_delete_confirm_body": "Dieses Ereignis wird dauerhaft entfernt.", + "event_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.", "error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 234437ad..76fbba6b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1046,6 +1046,31 @@ "timeline_derived_birth": "Birth", "timeline_derived_death": "Death", "timeline_derived_marriage": "Marriage", + "event_editor_new_title": "New event", + "event_editor_edit_title": "Edit event", + "event_editor_section_when": "When", + "event_editor_section_persons": "People involved", + "event_editor_section_documents": "Linked letters", + "event_editor_section_description": "Description", + "event_editor_title_label": "Title", + "event_editor_title_placeholder": "Event title", + "event_editor_title_required": "Please enter a title.", + "event_editor_date_required": "Please enter a date.", + "event_editor_type_label": "Type", + "event_editor_persons_label": "People", + "event_editor_documents_label": "Letters", + "event_editor_description_label": "Description", + "event_editor_description_placeholder": "Optional description", + "event_editor_persons_empty": "No person linked yet", + "event_editor_documents_empty": "No document linked yet", + "event_type_PERSONAL": "Personal", + "event_type_HISTORICAL": "Historical", + "event_editor_save": "Save", + "event_editor_save_hint": "Events appear on the timeline.", + "event_editor_delete": "Delete", + "event_editor_delete_confirm_title": "Delete event?", + "event_editor_delete_confirm_body": "This event will be permanently removed.", + "event_editor_unsaved_changes": "You have unsaved changes — really leave?", "error_geschichte_not_found": "The story was not found.", "error_journey_item_not_found": "The journey item was not found.", "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index aab54c4d..11d0f034 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1046,6 +1046,31 @@ "timeline_derived_birth": "Nacimiento", "timeline_derived_death": "Fallecimiento", "timeline_derived_marriage": "Matrimonio", + "event_editor_new_title": "Nuevo evento", + "event_editor_edit_title": "Editar evento", + "event_editor_section_when": "Cuándo", + "event_editor_section_persons": "Personas involucradas", + "event_editor_section_documents": "Cartas vinculadas", + "event_editor_section_description": "Descripción", + "event_editor_title_label": "Título", + "event_editor_title_placeholder": "Título del evento", + "event_editor_title_required": "Por favor, introduzca un título.", + "event_editor_date_required": "Por favor, introduzca una fecha.", + "event_editor_type_label": "Tipo", + "event_editor_persons_label": "Personas", + "event_editor_documents_label": "Cartas", + "event_editor_description_label": "Descripción", + "event_editor_description_placeholder": "Descripción opcional", + "event_editor_persons_empty": "Aún no hay ninguna persona vinculada", + "event_editor_documents_empty": "Aún no hay ningún documento vinculado", + "event_type_PERSONAL": "Personal", + "event_type_HISTORICAL": "Histórico", + "event_editor_save": "Guardar", + "event_editor_save_hint": "Los eventos aparecen en la cronología.", + "event_editor_delete": "Eliminar", + "event_editor_delete_confirm_title": "¿Eliminar evento?", + "event_editor_delete_confirm_body": "Este evento se eliminará de forma permanente.", + "event_editor_unsaved_changes": "Tienes cambios sin guardar — ¿salir de todos modos?", "error_geschichte_not_found": "No se encontró la historia.", "error_journey_item_not_found": "No se encontró el elemento del viaje.", "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", -- 2.49.1 From 423aedcd8753efe0a413937213eecb6ae93cd96a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:38:00 +0200 Subject: [PATCH 05/29] fix(a11y): 44px remove targets + empty states on person/document pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PersonMultiSelect and DocumentMultiSelect remove buttons were ~12px tap targets (below the 44px WCAG minimum) — pad them to min-h/min-w 44px with a focus-visible ring (SVG stays 12px). Add an optional emptyLabel slot inside the chip container and a hiddenInputName prop on PersonMultiSelect (mirroring DocumentMultiSelect) so EventForm can wire personIds without touching WhoWhenSection. Document the intentional bare typeahead fetch in documentTypeahead.ts (same-origin in prod, Vite-proxied in dev). Refs #781 Co-Authored-By: Claude Opus 4.8 --- .../lib/document/DocumentMultiSelect.svelte | 11 +++++++++-- frontend/src/lib/document/documentTypeahead.ts | 4 ++++ .../src/lib/person/PersonMultiSelect.svelte | 18 +++++++++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index c95b9cff..6bf8ed02 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -11,12 +11,15 @@ interface Props { selectedDocuments?: DocumentOption[]; placeholder?: string; hiddenInputName?: string; + /** Empty-state text shown inside the chip container when nothing is selected. */ + emptyLabel?: string; } let { selectedDocuments = $bindable([]), placeholder = m.geschichte_editor_search_document(), - hiddenInputName = 'documentIds' + hiddenInputName = 'documentIds', + emptyLabel = undefined }: Props = $props(); let searchTerm = $state(''); @@ -73,7 +76,7 @@ function removeDocument(id: string | undefined) { + {/each} +
+ + + +
{announcement}
diff --git a/frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts b/frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts new file mode 100644 index 00000000..f95708c5 --- /dev/null +++ b/frontend/src/lib/timeline/EventTypeSelect.svelte.spec.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import EventTypeSelect from './EventTypeSelect.svelte'; + +afterEach(() => cleanup()); + +describe('EventTypeSelect — segmented PERSONAL/HISTORICAL radio', () => { + it('renders exactly two radio options', async () => { + render(EventTypeSelect, { value: 'PERSONAL' }); + const radios = document.querySelectorAll('[role="radio"]'); + expect(radios.length).toBe(2); + }); + + it('marks the initial value as checked and seeds the hidden input', async () => { + render(EventTypeSelect, { value: 'HISTORICAL', name: 'type' }); + const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement; + expect(hidden.value).toBe('HISTORICAL'); + }); + + it('selects HISTORICAL and updates the hidden input when clicked', async () => { + render(EventTypeSelect, { value: 'PERSONAL', name: 'type' }); + await page.getByRole('radio', { name: 'Historisch' }).click(); + const hidden = document.querySelector('input[type="hidden"][name="type"]') as HTMLInputElement; + expect(hidden.value).toBe('HISTORICAL'); + }); +}); -- 2.49.1 From 15ff6db1d339507207f2187ea8da2bffaec88ab3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:45:39 +0200 Subject: [PATCH 07/29] feat(timeline): add EventForm curator create/edit form One component for both routes: /new renders it empty, /[id]/edit seeds it from a TimelineEventView. Composes EventTypeSelect, the shared DatePrecisionField, a plain-textarea description, PersonMultiSelect and DocumentMultiSelect (personIds /documentIds hidden inputs). lg:grid-cols-[2fr_1fr] collapsing to one column below lg, sticky save bar, beforeNavigate unsaved-changes guard, submitting flag via use:enhance (disabled submit), and a delete form gated by getConfirmService() read lazily so the component mounts cleanly in isolation. Title/description/chip labels render via default {...} escaping (CWE-79). Seeded DocumentRefs degrade gracefully to DocumentOption (no precision fields). Pickers gain an inputId prop so
- {#if dateError} - - {/if} diff --git a/frontend/src/lib/timeline/EventForm.svelte.spec.ts b/frontend/src/lib/timeline/EventForm.svelte.spec.ts index 05610533..4a3d9998 100644 --- a/frontend/src/lib/timeline/EventForm.svelte.spec.ts +++ b/frontend/src/lib/timeline/EventForm.svelte.spec.ts @@ -73,6 +73,15 @@ describe('EventForm — required-field error (REQ-010)', () => { }); }); +describe('EventForm — server date error wired per-field (REQ-011)', () => { + it('marks the date field aria-invalid and shows the message on a server date error', async () => { + render(EventForm, { form: { dateError: 'Bitte ein Datum eingeben.' } }); + await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument(); + const dateInput = document.querySelector('#eventDate') as HTMLInputElement; + expect(dateInput.getAttribute('aria-invalid')).toBe('true'); + }); +}); + describe('EventForm — submitting state (named AC)', () => { it('renders an enabled submit button initially', async () => { render(EventForm, { event: makeEvent() }); -- 2.49.1 From d48a89ba5c0c66cbd55ad72721e9b757cc633b77 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:33:01 +0200 Subject: [PATCH 18/29] =?UTF-8?q?test(timeline):=20assert=20submit-disable?= =?UTF-8?q?d=20transition=20and=20=E2=89=A544px=20chip=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the review-flagged test gaps (no production change): - submitting state: the old test only asserted the button was initially enabled (a tautology). Now stub a never-resolving fetch, click Speichern, and assert the button gains `disabled` — exercising the double-submit guard (Decision 8). - the two redirect-throwing save tests now use `await expect(...).rejects` so a future missing redirect fails loudly instead of being swallowed by try/catch. - the YEAR end-date-hide assertion uses getByLabelText('Enddatum') not-present, symmetric with the RANGE reveal, instead of a raw #eventDateEnd querySelector. - PersonMultiSelect + DocumentMultiSelect: assert the chip remove button carries the min-h-[44px]/min-w-[44px] target the rtm cites for REQ-017. Addresses PR #832 review (Tester + Requirements Engineer test-coverage concerns). Co-Authored-By: Claude Opus 4.8 --- .../DocumentMultiSelect.svelte.spec.ts | 10 +++++++++ .../person/PersonMultiSelect.svelte.spec.ts | 13 +++++++++++ .../src/lib/timeline/EventForm.svelte.spec.ts | 22 ++++++++++++++----- .../zeitstrahl/events/new/page.server.spec.ts | 20 +++++++---------- 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts index b8af8aac..b9c7d126 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte.spec.ts @@ -157,4 +157,14 @@ describe('DocumentMultiSelect — remove', () => { document.querySelector('input[type="hidden"][name="documentIds"]') ).toBeNull(); }); + + // REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience. + it('renders a ≥44px touch target on the chip remove button', async () => { + render(DocumentMultiSelect, { + selectedDocuments: [docFactory('d1', 'Brief A')] + }); + const removeBtn = (await page.getByLabelText('Entfernen').element()) as HTMLElement; + expect(removeBtn.className).toContain('min-h-[44px]'); + expect(removeBtn.className).toContain('min-w-[44px]'); + }); }); diff --git a/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts index 86c26274..16f6c7a7 100644 --- a/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts @@ -258,6 +258,19 @@ describe('PersonMultiSelect – removing persons', () => { await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); }); + // REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience. + it('renders a ≥44px touch target on the chip remove button', async () => { + render(PersonMultiSelect, { + selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }] + }); + const removeBtn = (await page + .getByRole('button', { name: 'Entfernen' }) + .first() + .element()) as HTMLElement; + expect(removeBtn.className).toContain('min-h-[44px]'); + expect(removeBtn.className).toContain('min-w-[44px]'); + }); + it('removes the corresponding hidden input when a chip is removed', async () => { render(PersonMultiSelect, { selectedPersons: [ diff --git a/frontend/src/lib/timeline/EventForm.svelte.spec.ts b/frontend/src/lib/timeline/EventForm.svelte.spec.ts index 4a3d9998..4e3a0d96 100644 --- a/frontend/src/lib/timeline/EventForm.svelte.spec.ts +++ b/frontend/src/lib/timeline/EventForm.svelte.spec.ts @@ -1,10 +1,13 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import EventForm from './EventForm.svelte'; import type { components } from '$lib/generated/api'; -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); type TimelineEventView = components['schemas']['TimelineEventView']; @@ -39,7 +42,7 @@ describe('EventForm — date precision RANGE reveal (headline AC, REQ-008/009)', it('hides the end-date field when precision is YEAR', async () => { render(EventForm, { event: makeEvent({ precision: 'YEAR' }) }); await expect.element(page.getByTestId('end-date-region')).toBeInTheDocument(); - expect(document.querySelector('#eventDateEnd')).toBeNull(); + await expect.element(page.getByLabelText('Enddatum')).not.toBeInTheDocument(); }); }); @@ -82,11 +85,20 @@ describe('EventForm — server date error wired per-field (REQ-011)', () => { }); }); -describe('EventForm — submitting state (named AC)', () => { - it('renders an enabled submit button initially', async () => { +describe('EventForm — submitting state (named AC, Decision 8)', () => { + it('disables the submit button while submitting', async () => { + // A never-resolving fetch keeps use:enhance in flight so the disabled + // transition (the double-submit guard) is observable rather than racing the + // reset in the result callback. + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => {})) + ); render(EventForm, { event: makeEvent() }); const btn = page.getByRole('button', { name: 'Speichern' }); await expect.element(btn).not.toBeDisabled(); + await btn.click(); + await expect.element(btn).toBeDisabled(); }); }); diff --git a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts index 83a6482d..208dabdf 100644 --- a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts +++ b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts @@ -113,8 +113,8 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => { .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); - try { - await actions.save( + await expect( + actions.save( saveEvent({ title: 'Umzug', type: 'PERSONAL', @@ -122,10 +122,8 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => { precision: 'YEAR', eventDateEnd: '1925-05-01' }) - ); - } catch { - // redirect throws on success - } + ) + ).rejects.toMatchObject({ status: 303 }); expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull(); }); @@ -135,18 +133,16 @@ describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => { .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); - try { - await actions.save( + await expect( + actions.save( saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01', precision: 'NOT_A_REAL_PRECISION' }) - ); - } catch { - // redirect throws on success - } + ) + ).rejects.toMatchObject({ status: 303 }); expect(post.mock.calls[0][1].body.precision).toBe('DAY'); }); -- 2.49.1 From 719274ef88162b28b7912537c7b50e5cc79517cc Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:33:38 +0200 Subject: [PATCH 19/29] docs(permissions): note requireWriteAll can replace the inline guard elsewhere Architect/Developer review suggestion: flag that other WRITE_ALL-gated author loads (e.g. documents/[id]/edit) still inline the throw-403 guard and can adopt requireWriteAll so it doesn't diverge. Comment-only. Addresses PR #832 review (Architect suggestion). Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/shared/server/permissions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/lib/shared/server/permissions.ts b/frontend/src/lib/shared/server/permissions.ts index bdd5d36c..bfb6f263 100644 --- a/frontend/src/lib/shared/server/permissions.ts +++ b/frontend/src/lib/shared/server/permissions.ts @@ -20,6 +20,10 @@ export function hasWriteAll(locals: PermissionLocals): boolean { * — `hasWriteAll` returns false for a null user, so a single check covers both * the unauthenticated and the under-privileged case. Server-side gate; the * frontend canWrite flag only hides entry-point buttons. + * + * Other WRITE_ALL-gated author loads (e.g. `documents/[id]/edit`) still inline + * `if (!hasWriteAll(locals)) throw error(403)` — they can adopt this helper so + * the guard doesn't quietly diverge across routes. */ export function requireWriteAll(locals: PermissionLocals): void { if (!hasWriteAll(locals)) throw error(403, 'Forbidden'); -- 2.49.1 From d330510777078b18c56356877bba6f870609b3e2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:39:38 +0200 Subject: [PATCH 20/29] test(document): update WhoWhenSection.test ids after DatePrecisionField extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DatePrecisionField extraction derives element ids from dateInputName, so the document form's precision/end-date ids changed (metaDatePrecision → documentDatePrecision, metaDateEnd → documentDateEnd, date-error → documentDate-error, end-date-error → documentDate-end-error). The name attributes are unchanged, so form submission is unaffected — but the pre-existing WhoWhenSection.svelte.test.ts (a separate file from the .spec.ts) still queried the old ids and was failing 5 assertions in CI's client project. It wasn't in the PR diff, so the multi-persona review missed it. Re-point the selectors. Addresses PR #832 review (round-1 clean-agent blocker). Co-Authored-By: Claude Opus 4.8 --- .../document/WhoWhenSection.svelte.test.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts index 136c260c..b2c795be 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts @@ -15,14 +15,14 @@ describe('WhoWhenSection — date input behavior', () => { await vi.waitFor(() => { // Invalid → border-red-400 class expect(dateInput.className).toContain('border-red-400'); - expect(document.querySelector('#date-error')).not.toBeNull(); + expect(document.querySelector('#documentDate-error')).not.toBeNull(); }); }); it('does not show the error before the user has typed', async () => { render(WhoWhenSection, {}); - const error = document.querySelector('#date-error'); + const error = document.querySelector('#documentDate-error'); expect(error).toBeNull(); }); @@ -77,20 +77,20 @@ describe('WhoWhenSection — precision controls', () => { it('renders a labelled precision select', async () => { render(WhoWhenSection, {}); - const label = document.querySelector('label[for="metaDatePrecision"]'); - const select = document.querySelector('select#metaDatePrecision[name="metaDatePrecision"]'); + const label = document.querySelector('label[for="documentDatePrecision"]'); + const select = document.querySelector('select#documentDatePrecision[name="metaDatePrecision"]'); expect(label).not.toBeNull(); expect(select).not.toBeNull(); }); it('hides the end-date field unless precision is RANGE', async () => { render(WhoWhenSection, { precision: 'DAY' }); - expect(document.querySelector('input#metaDateEnd')).toBeNull(); + expect(document.querySelector('input#documentDateEnd')).toBeNull(); }); it('reveals the end-date field when precision is RANGE', async () => { render(WhoWhenSection, { precision: 'RANGE' }); - expect(document.querySelector('input#metaDateEnd')).not.toBeNull(); + expect(document.querySelector('input#documentDateEnd')).not.toBeNull(); }); it('never renders the raw cell, and never re-submits it via a hidden input', async () => { @@ -110,9 +110,9 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => { endDateIso: '1917-01-10' }); - const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + const end = document.querySelector('input#documentDateEnd') as HTMLInputElement; await vi.waitFor(() => { - expect(document.querySelector('#end-date-error')).not.toBeNull(); + expect(document.querySelector('#documentDate-end-error')).not.toBeNull(); expect(end.getAttribute('aria-invalid')).toBe('true'); expect(end.className).toContain('border-red-400'); }); @@ -125,14 +125,16 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => { endDateIso: '1917-01-10' }); - await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull()); + await vi.waitFor(() => + expect(document.querySelector('#documentDate-end-error')).not.toBeNull() + ); - const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + const end = document.querySelector('input#documentDateEnd') as HTMLInputElement; end.value = '12.01.1917'; // now after the start end.dispatchEvent(new Event('input', { bubbles: true })); await vi.waitFor(() => { - expect(document.querySelector('#end-date-error')).toBeNull(); + expect(document.querySelector('#documentDate-end-error')).toBeNull(); expect(end.getAttribute('aria-invalid')).not.toBe('true'); }); }); @@ -144,6 +146,6 @@ describe('WhoWhenSection — end-before-start inline validation (#678)', () => { endDateIso: '1917-01-10' }); - expect(document.querySelector('#end-date-error')).toBeNull(); + expect(document.querySelector('#documentDate-end-error')).toBeNull(); }); }); -- 2.49.1 From 9cb856b376b6db9e4dcb7f928a881f4a8e491a65 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:44:25 +0200 Subject: [PATCH 21/29] test(e2e): update document-title-autosync precision selector to new id DatePrecisionField derives the precision select's id from dateInputName, so the document form's id moved from #metaDatePrecision to #documentDatePrecision (the name attribute is unchanged). This maintained E2E still queried the old id and would fail when run. Not CI-gated, but a real silent breakage. Addresses PR #832 review (round-2 clean-agent out-of-diff finding). Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/document-title-autosync.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/e2e/document-title-autosync.spec.ts b/frontend/e2e/document-title-autosync.spec.ts index 2f1bcb91..fc276151 100644 --- a/frontend/e2e/document-title-autosync.spec.ts +++ b/frontend/e2e/document-title-autosync.spec.ts @@ -26,7 +26,7 @@ test.describe('Document auto-title sync (#726)', () => { // 3. Add a YEAR-precision date WITHOUT touching the title, then save. await page.locator('#documentDate').fill('15.01.1928'); - await page.locator('#metaDatePrecision').selectOption('YEAR'); + await page.locator('#documentDatePrecision').selectOption('YEAR'); await page.getByRole('button', { name: 'Speichern', exact: true }).click(); // 4. The detail page shows the regenerated title carrying the new year. -- 2.49.1 From 0862d43ba3f24a9e2998ce9a2082ea03cdcf8752 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:44:45 +0200 Subject: [PATCH 22/29] fix(timeline): mark the event form dirty on date/precision/picker changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The beforeNavigate unsaved-changes guard only fired for title/type/description (their oninput/onchange hooks set `dirty`). Editing only the date, precision, end-date, or the linked persons/documents left `dirty` false, so a curator could navigate away and silently lose those edits — defeating the guard for the senior author audience. Add an $effect that watches those values and marks dirty on any change after the initial prop snapshot (first run only arms the watcher). Addresses PR #832 review (round-2 clean-agent concern). Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/EventForm.svelte | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/lib/timeline/EventForm.svelte b/frontend/src/lib/timeline/EventForm.svelte index 46e51e98..38d752ea 100644 --- a/frontend/src/lib/timeline/EventForm.svelte +++ b/frontend/src/lib/timeline/EventForm.svelte @@ -105,6 +105,22 @@ function markDirty() { dirty = true; } +// title/type/description set `dirty` through their own oninput/onchange hooks, +// but the date/precision/end-date (inside DatePrecisionField) and the two pickers +// have no such hook — so changing only a date or a linked person would otherwise +// slip past the beforeNavigate guard. Watch those values and mark dirty on any +// change after the initial prop snapshot (the first run only arms the watcher). +let dirtyArmed = $state(false); +$effect(() => { + void dateIso; + void precision; + void endDateIso; + void selectedPersons.length; + void selectedDocuments.length; + if (dirtyArmed) dirty = true; + else dirtyArmed = true; +}); + // Guards a submit with a blank title client-side. The server re-validates and // owns the authoritative fail(400) with per-field flags. function handleSubmit(e: SubmitEvent) { -- 2.49.1 From 6150fc7be5d3490459befcc64a8c40eff44192ae Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 14 Jun 2026 00:55:34 +0200 Subject: [PATCH 23/29] fix(timeline): track form dirtiness via change callbacks, not an $effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-2 dirty-guard used an $effect that both read and wrote its `dirtyArmed` $state, so the self-write re-triggered the effect and `dirty` flipped true on mount — the beforeNavigate confirm then fired on every navigation away from an untouched form (caught by the round-3 clean-agent review + the Svelte autofixer, which flags assigning state inside $effect). Replace it with the component's existing idiomatic pattern: DatePrecisionField, PersonMultiSelect, and DocumentMultiSelect each gain an optional `onchange` callback fired on a real user edit, and EventForm passes `markDirty` to all three. Now date/precision/end-date and picker add/remove mark the form dirty exactly like title/type/description already did — no effect, no mount-timing trap. The new props are optional, so the other consumers (WhoWhenSection, the document forms) are unaffected. Svelte autofixer: clean. Addresses PR #832 review (round-3 clean-agent concern). Co-Authored-By: Claude Opus 4.8 --- .../lib/document/DocumentMultiSelect.svelte | 7 +++++- .../src/lib/person/PersonMultiSelect.svelte | 7 +++++- .../primitives/DatePrecisionField.svelte | 6 +++++ frontend/src/lib/timeline/EventForm.svelte | 24 +++++++------------ 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/document/DocumentMultiSelect.svelte b/frontend/src/lib/document/DocumentMultiSelect.svelte index f190c5fb..e736b13d 100644 --- a/frontend/src/lib/document/DocumentMultiSelect.svelte +++ b/frontend/src/lib/document/DocumentMultiSelect.svelte @@ -15,6 +15,8 @@ interface Props { emptyLabel?: string; /** id of the search input so a