Timeline: curator event create/edit forms #781
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Frontend"Depends on: #3 (TimelineEvent CRUD API —
@RequirePermission(WRITE_ALL)on POST/PUT/DELETE), #6 (sharedTimelineEventtypes +dateLabel.tsprecision rendering).Build-time prerequisite:
npm run generate:apimust have run after #3 soTimelineEvent/TimelineEventRequesttypes exist before this work starts. If #9 is merged before #3, CI'sgenerate:apistep will fail — #3 must merge first.Overview
Curator UI to create/edit/delete hand-curated timeline events, gated to
WRITE_ALL. Pure frontend: two SvelteKit routes plus reused components. No new backend package, entity, migration, orErrorCode— therefore no ADR and no DB-diagram update is triggered here (thetimeline/domain ADR belongs to #2/#3).Doc updates triggered by this issue: add the
events/newandevents/[id]/editchildren to theCLAUDE.mdroute table and the frontend C4 diagram (alongside/zeitstrahlfrom #7).Scope
/zeitstrahl/events/new— empty create form./zeitstrahl/events/[id]/edit— edit form seeded fromGET /api/timeline/events/{id}; includes the delete control.Architecture & reuse
EventForm.sveltetaking optionalevent+initialPersons/initialDocumentsprops./newrenders it empty;/[id]/editrenders it seeded. Do not fork the markup.DatePrecisionField.svelteinto$lib/shared/primitives/(not$lib/timeline/). It is a generic date-input primitive shared by two domains (document/viaWhoWhenSectionandtimeline/viaEventForm); placing it in either consumer's domain would create a cross-domain import. No new lib-level diagram entry is needed.WhoWhenSectionalready implements the exact logic (Germandd.mm.yyyyinput viahandleGermanDateInput+ hidden ISO input,dateDirtytracking,endBeforeStartguard,aria-live="polite"on the revealed end-date). Refactor that region into the shared primitive and have bothWhoWhenSectionandEventFormconsume it. Do NOT importWhoWhenSectionwholesale (it carries sender/receiver/location concerns that do not belong on a timeline event).aria-live="polite"wrapper around the entire{#if showEndDate}region (not just the error text) — do not move it to a child element.data-testid="end-date-region"attribute on the RANGE-revealed end-date container so thatWhoWhenSection.svelte.spec.ts's existingdata-testid="who-when-end-date"anddata-testid="who-when-precision"selectors survive the refactor (coordinate naming with the existing spec).precisionandendDateIsomust remain$bindableprops so the parent can read values (theWhoWhenSectionbindable pattern must be preserved).EventTypeSelect.svelte— its own tiny component. Follow thePersonTypeSelector.sveltereference verbatim:role="radiogroup"withrole="radio"buttons,aria-checked,tabindexroving,radioGroupNavaction from$lib/shared/actions/. Two options: PERSONAL / HISTORICAL. Rendered as a segmented radio group (not a<select>): each option pairs a localized text label with a decorative icon (aria-hidden="true") for the person/world accent — no color-only differentiation.PersonMultiSelect.svelte(already exists at$lib/person/PersonMultiSelect.svelte, already consumed byWhoWhenSection— just wire it intoEventForm) andDocumentMultiSelect.svelte(emits adocumentIdshidden input, form-actions-ready). Each needs an associated<label>(not placeholder-as-label), a visible empty state ("Noch keine Person verknüpft" / "Noch kein Dokument verknüpft" / localized), and ≥44px remove targets on chips (addmin-h-[44px] min-w-[44px]or equivalent padding expansion on the remove button — the currentDocumentMultiSelectremove button is ~12px and does not meet this requirement).DocumentMultiSelecttypeahead auth: the existing component firesfetch('/api/documents/search?q=...')as a bare browser call — the same pattern as the Geschichte editor. This is intentional: in dev the Vite proxy forwards/api; in prod the app is same-origin. Add a code comment to the component making this explicit. Do not refactor to an internal+server.tsproxy (no practical security benefit, adds complexity).+page.server.tsload+ formaction(SSR) — never a clientfetch('/api/...')in the page or form layer. Do not use thegeschichten/newcsrfFetch+goto()pattern; use SvelteKit form actions.getConfirmService()(globally mountedConfirmDialogin the root layout) +use:enhancecancel pattern as shown in the confirm service JSDoc. Do not build a bespoke dialog —ConfirmDialogalready handlesaria-modal, keyboard trap, and the destructive button variant.Routes & gating
throw error(403, 'Forbidden'), thepersons/newidiom). This is the project convention for permission-gated author routes; it is more honest about why the user was stopped than a silent redirect. Theloadfunction readslocals.user.groupsserver-side to make the decision — never gate route access on a client-deriveddata.canWriteflag (that flag is only for hiding entry-point buttons elsewhere). Thegroupscheck is a string comparison against'WRITE_ALL'(mirroringPermission.WRITE_ALLexactly) — add a comment to that effect.@RequirePermission(WRITE_ALL)on the CRUD endpoints (#3). A hand-crafted POST is stopped by the backend, not by this code. The AC "non-curators cannot reach the forms" is satisfied by theloadguard returning 403.GET /api/timeline/events/{id}404s for them. Guard the load:!result.response.ok→throw error(404). This covers all non-ok responses (not just 404). Never render a blank editable form that silently POSTs a new event.?personId=/?documentId=query params (mirrorgeschichten/new): pre-selects that person/document for the "Ereignis hinzufügen" flow from a Person's Lebensweg. Silently swallow 404/403 on prefill lookups — checkpersonResult && personResult.response.ok && personResult.dataand returninitialPersons: []on any failure; do not throw. This avoids leaking entity existence on unknown IDs (copygeschichten/new's comment-documented behavior verbatim).Form fields & validation
Fields: title (required), type (PERSONAL/HISTORICAL, segmented radio), eventDate + precision selector (required), eventDateEnd (shown only when precision = RANGE), description (optional), person picker (optional), document picker (optional).
eventDateEndexplicitly asnullwhen precision flips away from RANGE on an edit — theDatePrecisionFielddoes this viavalue={showEndDate ? endDateIso : ''}— so a stale end-date does not persist. The form action must convert the empty string tonullin theTimelineEventRequestbody.!result.response.ok; useresult.data!after the ok-check; map errors viagetErrorMessage(extractErrorCode(result.error as unknown as { code?: string })). Never readresult.errordirectly.title,eventDate) marked with*+aria-required; on error use icon + text +aria-invalid, never red border alone. RANGE end-date-before-start surfaces the same inline ⚠ cue asWhoWhenSection; the backend (#3) owns the hard rejection.submittingreactive flag viause:enhanceand applydisabled={submitting}on the submit button. This prevents double-submit and provides essential feedback for the senior audience on slower connections (authors on 60+ laptop/tablet). Theuse:enhancecallback setssubmitting = trueon start and resets it on complete.aria-live="polite"on the revealed end-date. InheritWhoWhenSection's treatment; do not regress it. Card pattern per section (rounded-sm border border-line bg-surface shadow-sm p-6), not one flat stack.<h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">section titles. Runaxeon the route in both light and dark mode (dark mode remaps all color tokens — verify end-date error text contrast).<BackButton>, never a static<a href>.{#each}on precision options and picker chips ((p.value),(person.id)).ErrorCode/errors.tswork needed.Delete
/[id]/editform, behindgetConfirmService()confirmation →DELETE /api/timeline/events/{id}→ redirect. Keeps this issue self-contained, fulfills the AC, and matches the CRUD-form mental model. Card-level delete in the #7 timeline view is a separate, additive concern.ConfirmDialog's destructive button variant; the dialog already handles keyboard trapping andaria-modal.ConfirmDialog.sveltepatch: addaria-modal="true"to the<dialog>element (one-line patch, benefits all uses). The HTML<dialog showModal()>has implicit modal semantics in modern browsers butaria-modal="true"is best practice for older AT.!ok, returnfail(status, { error: getErrorMessage(...) })and surface the error in the form — do not redirect.Navigation target (post-save / post-delete)
Context-aware redirect. If the form was launched via
?personId=, return to that person's page (/persons/{personId}); otherwise redirect to/zeitstrahl. Thread the originating context through a hidden<input type="hidden" name="originPersonId">field. ValidateoriginPersonId: in the action, accept the value only if it is a non-empty UUID-format string; default to/zeitstrahlon empty or malformed values to prevent an open redirect on a crafted hidden field.Validation failure re-render (picker persistence)
When the form action returns
fail(400, { ... }), includepersonIdsanddocumentIdsarrays in the payload so the pickers re-populate on re-render. Theloadfunction seedsinitialPersons/initialDocumentsfrom these IDs on re-render (same path as the prefill lookup). This matches the "re-rendered with entered values preserved" AC for all fields, not just scalars.Acceptance criteria (Given-When-Then)
/newor/[id]/edit, then they can create, edit, and delete events. Given a non-curator (logged out or READ_ALL only), when they navigate to either route, then a 403 error page is shown.eventDateEndis submitted asnull(not a stale date or empty string).eventDateEndis sent asnull./zeitstrahl(or the originating person page if launched via?personId); when the DELETE fails, then the error is surfaced in the form and no redirect occurs.initialPersons/initialDocuments(prefill), when the form renders, then those persons/documents are pre-selected; given an unknown prefill id, then it is silently ignored (no "not found" leak).Tests
Component (
*.svelte.spec.ts, vitest-browser, single-file local runs):should_reveal_end_date_field_when_precision_is_RANGEandshould_hide_end_date_field_when_precision_is_YEAR(headline AC). Assert viagetByLabelText/getByRole+toBeVisible()/not-present, not internal state.should_preselect_person_when_initialPersons_provided.should_show_required_error_when_title_is_blank.should_submit_null_for_eventDateEnd_when_precision_changed_from_RANGE_to_YEAR.makeEvent(overrides)returning theTimelineEventshape — do not repeat the builder per test. Document the minimal required shape in a comment in the spec file oncenpm run generate:apihas run after #3.Server (
page.server.spec.ts, importload/actions, mock fetch/api client):loadthrows 403 for a non-curator (mirrorpersons/new/page.server.spec.ts).loadon/[id]/editthrows 404 when the API returns!ok(covers derived-event / unknown-id / any non-ok status — regression test).fail(400, { ..., personIds, documentIds })on blank title with all values preserved (including picker arrays).getErrorMessageandredirect(303)on success.fail(status, { error: getErrorMessage(...) })when DELETE returns!ok.originPersonIdredirect validation: action defaults to/zeitstrahlwhenoriginPersonIdis empty, non-UUID, or absent.Regression (
WhoWhenSection.svelte.spec.ts):DatePrecisionFieldextraction — verify existingdata-testidselectors (who-when-end-date,who-when-precision) are preserved and no behavior regresses.E2E (Playwright — keep thin, one critical journey + security counterpart):
/zeitstrahl. (Full "sees the event card" assertion depends on #7 — if this issue ships before #7, scope to HTTP 200 only, not card presence.)/zeitstrahl/events/newis blocked (403). Do not push field/precision permutations to E2E — those belong at the component layer.Task checklist
ConfirmDialog.svelte: addaria-modal="true"to the<dialog>element (one-line patch).DatePrecisionField.svelteinto$lib/shared/primitives/fromWhoWhenSection's date+precision+RANGE-end-date region; preservearia-live="polite"around the full{#if showEndDate}block,$bindableprops forprecision/endDateIso, anddata-testidattributes compatible with the existingWhoWhenSectionspec. RefactorWhoWhenSectionto consume it.WhoWhenSection.svelte.spec.tsgreen after extraction — verify no data-testid selectors or behavior regressed.EventTypeSelect.svelte— segmented radio group modelled onPersonTypeSelector.svelte(role="radiogroup",radioGroupNav,tabindexroving); icon (aria-hidden="true") + text label per option; two options: PERSONAL / HISTORICAL.EventForm.svelte— title, type,DatePrecisionField, description,PersonMultiSelect,DocumentMultiSelect; optionalevent/initialPersons/initialDocumentsprops; conditional-spread optionals; explicitnullforeventDateEndoff RANGE;submittingflag +disabled={submitting}on submit./zeitstrahl/events/new(+page.server.tsload + action) with?personId/?documentIdprefill (404/403 swallowed, returninitialPersons: [])./zeitstrahl/events/[id]/edit(load seeds form,throw error(404)on any!okresponse; action for update + delete; delete usesgetConfirmService()).fail(status, { error })on DELETE!ok; redirect on success.originPersonIdvalidation in action: UUID-format check; default to/zeitstrahlon invalid/empty.fail(400)payload includespersonIds/documentIdsarrays so pickers re-populate on re-render.locals.user.groups; string comparison against'WRITE_ALL'; add comment mirroringPermission.WRITE_ALL); hide entry-point buttons for non-curators.DocumentMultiSelectremove button: addmin-h-[44px] min-w-[44px](or equivalent padded hit area). Add code comment that barefetch('/api/...')is intentional (same-origin in prod, Vite-proxied in dev — matches Geschichte editor).PersonMultiSelectremove button: verify ≥44px target; fix if needed.<BackButton>; card-pattern sections; senior-a11y treatment;axelight+dark.CLAUDE.mdroute table + frontend C4 diagram withevents/newandevents/[id]/editchildren.Decisions resolved (Round 1)
getConfirmService()confirmation, DELETE then redirect. Rationale: keeps the issue self-contained, fulfills the AC's "delete" now rather than deferring to #7, and matches the standard CRUD-form mental model.?personId=, else/zeitstrahl. Thread via hiddenoriginPersonIdfield; validate as UUID before using in redirect. Rationale: friendliest for the "add event from a person" flow at trivial cost, with a safe default and open-redirect protection.persons/newidiom), adopted as the project convention for permission-gated author routes. Rationale: honest about why access was denied; consistent with a server-sideloadguard; gentler redirects are appropriate for read paths, not author tools.PersonTypeSelector.svelte. Rationale: keyboard-trivial for a binary choice, provides redundant non-color cues out of the box.DatePrecisionField.sveltelocation →$lib/shared/primitives/. Rationale: it is a generic date-input primitive shared by two domains; placing it in either consumer's domain would create a cross-domain import; no new diagram entry needed.initialPersons/initialDocumentsinfail(400)payload → IncludepersonIds/documentIdsarrays in the fail payload and re-seed pickers on re-render. Rationale: consistent with "all entered values preserved" AC; dropping picker state on a required-field error is a significant UX regression for the senior audience.ConfirmDialogaria-modalpatch → Addaria-modal="true"to the<dialog>element now (one-line patch). Rationale: zero downside, benefits all dialog uses, best practice for AT compatibility across browsers.submittingflag +disabled={submitting}viause:enhance. Rationale: prevents double-submit and provides essential feedback for the senior audience on slower connections; trivial to implement withuse:enhance.DocumentMultiSelecttypeahead auth path → Keep barefetch('/api/...')as-is (intentional, matches Geschichte editor), add a code comment. Rationale: already works in dev (Vite proxy) and prod (same-origin); a+server.tsproxy adds complexity with no security benefit.🏗️ Markus Keller — Application Architect
Observations
geschichten/newgating inconsistency — do not copy that pattern. The issue correctly says to followpersons/new(throw error(403, 'Forbidden')). Good. I verifiedgeschichten/newusesredirect(303, '/geschichten')for non-writers — that's a weaker precedent that obscures why access was denied. The issue has already made the right call here; implementors should not reach forgeschichten/newas the gating template.canWritefrom layout data vs.locals.usercheck. The layout server exposescanWrite(from+layout.server.ts). The issue correctly directs theloadfunction to readlocals.user.groupsserver-side and throw 403 — not to derive the gate fromdata.canWrite.data.canWriteflows through the page props and could be stale on a navigation without a full load. The server-sidelocalscheck is the right approach; the comment mirroringPermission.WRITE_ALLis a good documentation touch.Doc update list is correct but incomplete in one area. The issue calls out
CLAUDE.mdroute table and frontend C4 diagram. Thetimeline/frontend lib directory ($lib/timeline/) belongs in theCLAUDE.mdlib structure table as well. For this issue specifically:EventForm.svelte,EventTypeSelect.svelte, andDatePrecisionField.svelteland in two directories.DatePrecisionFieldin$lib/shared/primitives/needs no new entry (it's a primitive).EventFormandEventTypeSelectshould land in$lib/timeline/per the spec — that directory needs aCLAUDE.mdlib entry when it is first created (that's issue #7's territory, but if this issue creates the directory, it triggers the update).No new DB migration, no backend package, no ADR. Correct. The issue is purely frontend.
DatePrecisionalready exists in thedocument/package — the extractedDatePrecisionField.sveltereuses the existing frontendDatePrecisiontype from$lib/shared/utils/documentDate. No cross-domain backend concern.originPersonIdopen-redirect protection. The UUID format check before usingoriginPersonIdin a redirect is correct and necessary. The form posts a hidden field — even though it's a same-origin redirect target, a crafted form submission could set it to an arbitrary path. Validating as UUID-format before constructing/persons/{originPersonId}is the right gate. Document the threat model in a comment in the action.WhoWhenSectionrefactor scope. TheDatePrecisionFieldextraction touchesWhoWhenSection.svelte, which is consumed by the document edit/upload forms. The regression test requirement is correctly identified. Thearia-live="polite"wrapper must stay on the outer<div>around{#if showEndDate}, not be moved inside the extracted component — the spec is explicit on this and it's the right call (the live region must announce when the region appears, not just the content inside it).Recommendations
loadfunction:// WRITE_ALL check mirrors Permission.WRITE_ALL — server-side gate; frontend canWrite flag is for hiding entry-point buttons only.This prevents a future developer from thinking thedata.canWriteflag is the real security boundary.$lib/timeline/as a directory (even if just forEventForm.svelte), add the entry toCLAUDE.mdlib structure table in the same commit.fail(400)payload must includepersonIdsanddocumentIdsas arrays (not objects). UseformData.getAll('personIds')to collect the multi-value hidden inputs. The issue specifies this; make sure theloadre-seeding path uses the same array-of-strings format the pickers expect.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
DatePrecisionFieldextraction — theonMountseeding pattern must be preserved.WhoWhenSectionuses anonMountto seeddateDisplayfromdateIso || initialDateIsoexactly once, so later prop changes don't stomp the user's input. This logic must transfer toDatePrecisionFieldunchanged. The existingWhoWhenSection.svelte.spec.tsdoesn't test end-date behavior or RANGE precision at all — it only tests the date field and location. The regression spec referenced in the issue will be the first test of the extracted end-date region's behavior.EventTypeSelect.svelte— two options, not four.PersonTypeSelector.sveltehas four types rendered in agrid-cols-2 sm:grid-cols-4grid.EventTypeSelectneeds onlyPERSONAL/HISTORICAL. TheradioGroupNavaction takes a callback with the selected value — wire it identically. Thesr-onlyaria-liveannouncement div (theannouncementstate +a11y_type_changedmessage) should be replicated inEventTypeSelectfor the same screen-reader UX.EventFormprops interface — use specific types. WhenTimelineEventandTimelineEventRequesttypes are generated from the OpenAPI spec (after #3 merges), theeventprop should be typed asTimelineEvent | undefined, notobjectorany. The factory functionmakeEvent(overrides)in the component spec file should document the minimal shape needed in a comment, as the issue directs.DocumentMultiSelectremove button — the hit-area fix is concrete. The current remove button class is"ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none"with a 12×12px SVG inside. Addingmin-h-[44px] min-w-[44px]to the button andflex items-center justify-centerwill expand the hit area without changing visual size. The SVG stays 12×12; the padding absorbs the target. Don't forgetPersonMultiSelect's remove button too — verify it before assuming it passes.Conditional spread for
eventDateEnd. The issue specifies sendingeventDateEnd: nullwhen precision leaves RANGE. TheDatePrecisionFieldhides the end-date input when!showEndDateand the hidden<input>emitsvalue={showEndDate ? endDateIso : ''}. The form action must convert empty string tonullin theTimelineEventRequestbody. This is a one-liner:const eventDateEnd = precision === 'RANGE' && endDateIsoRaw ? endDateIsoRaw : null. Getting this wrong leaves stale end-dates in the database from edit round-trips.$bindableonprecisionandendDateIso. These must be$bindablesoEventFormcan read the current values when assembling the form body (even though the form also has hidden inputs). The issue is correct that theWhoWhenSectionbindable pattern must be preserved. Don't add$stateredundantly alongside$bindable— the parent's binding IS the state.submittingflag pattern. Theuse:enhancecallback form:use:enhance={() => { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }}. Thedisabled={submitting}on the submit button should also visually reflect the state — add a spinner oropacity-50class while submitting for the senior audience.Recommendations
DatePrecisionFieldcomponent spec first (red), extract the component second (green). Theshould_reveal_end_date_field_when_precision_is_RANGEtest gives the red signal before any extraction happens.name="eventDate"to match the API field directly — avoids a translation step in the form action.EventForm, extract a$derivedforshowEndDate = $derived(precision === 'RANGE')rather than repeatingprecision === 'RANGE'in multiple places.DatePrecisionFieldextraction, runWhoWhenSection.svelte.spec.tsimmediately in isolation (--project=client src/lib/document/WhoWhenSection.svelte.spec.ts) before touchingEventForm— ensures the regression fence is green before adding new surface.🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
originPersonIdopen-redirect — the threat model is real. The hidden<input type="hidden" name="originPersonId">is user-controlled in the sense that a crafted form POST can set it to any string. The action's UUID format validation before using it inredirect(303, '/persons/{originPersonId}')is correct. I'd add: use a strict UUID regex, not just a non-empty check. In TypeScript:const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const safeOrigin = UUID_RE.test(originPersonIdRaw) ? \/persons/${originPersonIdRaw}` : '/zeitstrahl';`. This closes the redirect regardless of what a crafted form submits.DocumentMultiSelectbarefetch('/api/...')— confirmed intentional, confirmed safe. The component already does this pattern and the issue explicitly calls it intentional (same-origin in prod, Vite-proxied in dev). The auth cookie travels automatically on same-origin requests. Adding the code comment is the right call. No action needed beyond the comment.Frontend permission gate is UX, not a security boundary — correctly stated. The
loadguard returning 403 stops casual users; the backend@RequirePermission(WRITE_ALL)on the CRUD endpoints stops crafted requests. The issue is clear on this layering. One thing to verify during implementation: theGET /api/timeline/events/{id}endpoint (for the edit formload) is protected byREAD_ALLper the spec. A logged-out user navigating to/zeitstrahl/events/123/editshould hit the server-sideloadwhich checkslocals.user— iflocals.useris null, thecanWritecheck will throw 403 before the API call is even made. Confirm theloadfunction handles the unauthenticated case (null user) as 403, not as an unhandled TypeError.Server-side permission check implementation — string comparison risk. The issue directs using a string comparison against
'WRITE_ALL'. Looking atpersons/new, the pattern is:locals.user?.groups?.some((g) => g.permissions.includes('WRITE_ALL')) ?? false. This is safe becauseincludes()is exact-match on strings. The comment directing developers to mirrorPermission.WRITE_ALLis good hygiene — it prevents drift if the permission string ever changes. Recommend extracting a helperhasPermission(locals, 'WRITE_ALL')in$lib/shared/server/so the check is one line and centrally testable.fail(400)payload withpersonIds/documentIds— no injection risk. These are UUIDs collected from hidden<input>elements. They're re-used asGET /api/persons/{id}path params in theloadre-seeding path. Verify that theloadfunction validates each ID before passing to the API (the API will 404/403 on invalid IDs and the spec says to swallow those — so even a malformed ID just results in an empty picker, which is fine).ConfirmDialogaria-modalpatch — confirmed missing. I checked the source: the<dialog>element hasaria-labelledby="confirm-title"but noaria-modal="true". The one-line patch is correct. No security implication, but it is the right AT practice.Recommendations
originPersonIdvalidation. Document the threat:// Prevents open redirect: validate before constructing /persons/{id}. See OWASP: CWE-601.loadfunction, add an explicit null-user guard before thecanWritecheck:if (!locals.user) throw error(403, 'Forbidden');— this avoids a potential TypeError onlocals.user.groupswhen an unauthenticated request reaches a route that wasn't caught by the global auth hook.extractErrorCodepattern (result.error as unknown as { code?: string }) is correct per project conventions — do not use rawresult.error.messageanywhere in the form or action.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
WhoWhenSection.svelte.spec.tsregression scope is narrow — and that's a risk. The existing spec tests only: date pre-fill frominitialDateIso,hideDatemode,editModelocation field, andinitialLocationpre-fill. It does NOT test: precision selector behavior, RANGE end-date reveal, orendDateIsobinding. After theDatePrecisionFieldextraction, those behaviors live in the new primitive — but there are no existing tests for them that could go red. The regression fence for the extraction is therefore weaker than the issue implies. Recommendation: before extracting, write a test inWhoWhenSection.svelte.spec.tsthat asserts RANGE end-date reveals when precision = 'RANGE'. This gives a real red/green signal for the extraction.Component spec coverage gaps. The issue lists five component tests for
DatePrecisionField/EventForm. Missing from the list:should_disable_submit_button_while_submitting— thesubmittingflag is a named AC but has no named test.should_show_error_inline_when_api_returns_error_on_save— the form action maps API errors, but no component test verifies the error renders in the form.should_not_submit_eventDateEnd_when_precision_changes_from_RANGE_to_YEARis listed but needs clarification: at the component layer, this is asserting the hidden input's value, not the network payload. At the server test layer, the form action conversion of''→nullis what matters.Server spec coverage —
loadon/[id]/editwithfailpayload re-seeding. The issue specifies thatloadre-seeds pickers frompersonIds/documentIdsin the fail payload. But this is only triggered when the action returnsfail(400, { personIds, documentIds })and SvelteKit re-renders the page withdata.form. Theloadfunction itself doesn't participate in re-seeding — it's theformdata in the page that provides picker re-population. Verify whether the issue intends theloadto actively re-fetch from theform.personIdsor whether the page component readsform.personIdsdirectly to pre-populate picker state. This affects whether a server spec forloadre-seeding is even the right test location.E2E scope is correctly thin. The two E2E scenarios (curator creates event + non-curator blocked) are appropriate. The conditional scope note about #7 ("HTTP 200 only if #7 hasn't shipped") is good — it prevents a test that passes only in the combined state. Implement the E2E assertion as:
await expect(response.status()).toBe(200)on the/zeitstrahlredirect target, not a DOM assertion about card presence.delete action returns fail(status, { error })on!ok— need explicit test. The spec lists this as a server test. The test should verify: (a) DELETE returns 500 from API → action returnsfail(500, { error: getErrorMessage(...) })and (b) no redirect occurs. Useexpect(result.status).toBe(500)on the action return value and assert noLocationheader.WhoWhenSection.svelte.spec.tsdata-testidcompatibility. After extraction, the spec usespage.getByTestId('who-when-end-date')andpage.getByTestId('who-when-precision'). Thesedata-testidattributes live on the wrapping divs insideWhoWhenSection, not insideDatePrecisionFielditself. As long asDatePrecisionFieldis rendered inside those same wrapper divs inWhoWhenSection, the selectors survive. If the extraction moves those divs INTODatePrecisionField, the testids must be exposed as props or viadata-testidforwarding. The issue anticipatesdata-testid="end-date-region"on the range-revealed container — confirm this is on theDatePrecisionField's outer wrapper, not the inner content div.Recommendations
WhoWhenSection.svelte.spec.tsNOW (before extraction) that asserts RANGE end-date reveals:render(WhoWhenSection, { precision: 'RANGE' })→expect(getByLabelText('Enddatum')).toBeVisible(). This gives a true red before the extraction and green after.form.personIdsread directly in the component, write a component spec. If theloadre-fetches from those IDs, write a server spec. Don't write both without clarifying what each is testing.submittingflag: renderEventForm, trigger submit, assert button hasdisabledattribute before the action completes.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
ConfirmDialog.svelte—aria-modalconfirmed missing, but the<dialog>element's native modal semantics are already partly there. The dialog usesshowModal()via the$effect, which sets the nativeinertattribute on the rest of the DOM in modern browsers. However,aria-modal="true"is still required for older AT (NVDA + Chrome < 2022, VoiceOver on iOS < 16) that don't honor native dialog semantics. The one-line patch is correct and low risk.EventTypeSelect— icon + text per option. The issue specifies a "decorative icon (aria-hidden='true') for the person/world accent." These icons must be truly decorative (not the only differentiator between PERSONAL and HISTORICAL). The text labels ("Persönlich" / "Historisch" or equivalent i18n keys) must be visible alongside the icon — this is the redundant non-color cue requirement. The segmented radio group layout fromPersonTypeSelectoralready does this well; replicate the exact button classes includingmin-h-[48px].DocumentMultiSelectremove button — the 12×12px SVG is the entire tap target. The current markup is<button class="ml-0.5 text-ink/50 hover:text-red-500 focus:outline-none">. Tapping a 12px target on a 60+ author's laptop/tablet is genuinely difficult. The fix isclass="ml-0.5 min-h-[44px] min-w-[44px] inline-flex items-center justify-center text-ink/50 hover:text-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring". Also addrounded-smso the focus ring looks intentional, not clipped.Picker empty states — placement matters. "Noch keine Person verknüpft" and "Noch kein Dokument verknüpft" should render inside the picker's chip container when it has zero selected items, not below or outside. This makes the empty state feel like part of the field. Use
font-sans text-sm text-ink-3 italicto visually distinguish it from chip text. Don't use the same style as error text.aria-live="polite"wrapper position. The issue is explicit: thearia-live="polite"wrapper must surround the entire{#if showEndDate}block, not just the error text. Looking at the existingWhoWhenSection.svelte: line 149 has<div aria-live="polite">wrapping the{#if showEndDate}block, with thedata-testid="who-when-end-date"div inside it. This is the correct structure. When extractingDatePrecisionField, thearia-livediv must be on the component's outer wrapper element for the end-date region, not on a child. If the component emits a fragment, thearia-livewill be misplaced.Card pattern — use it for all sections. The issue specifies "card pattern per section."
EventFormshould wrap: (1) Wann / date section, (2) Wer / persons section, (3) Dokumente / documents section, (4) Beschreibung / description section — each in their ownrounded-sm border border-line bg-surface shadow-sm p-6card. Do not flatten all fields into one card. This matches how the document edit form structures its sections and aids the senior audience's spatial orientation.BackButton— already in$lib/shared/primitives/. Use it. The issue calls this out. Looking at the component, it handles back navigation via browser history or a fallback href. Use it on both routes.Dark mode — verify end-date error text contrast. The
text-red-600error color is used inWhoWhenSectionfor the⚠ endBeforeStartcue. In dark mode,text-red-600on dark backgrounds may not meet 4.5:1. The issue specifically calls for anaxecheck in dark mode. Runaxeon the edit form in dark mode before marking the issue done. If contrast fails, usetext-red-400in dark mode via a conditional or a semantic error token.Recommendations
EventTypeSelect, use exactly two buttons in agrid-cols-2layout (notsm:grid-cols-4likePersonTypeSelector). Each button: icon (aria-hidden="true", ~20×20px) + text label side by side withflex items-center gap-2.focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ringto theDocumentMultiSelectremove button fix so keyboard users see a clear focus indicator after expanding the hit area.<label>for each picker must use<label for="...">or wrap the picker in a<label>. Using<p class="...">as the label (which is the currentWhoWhenSectionpattern for receivers) is not semantically correct — it won't be announced as the field label by screen readers. Switch to<label>for the new pickers.⚙️ Tobias Wendt — DevOps & Platform Engineer
No infrastructure, CI pipeline, Docker, or deployment concerns are introduced by this issue. The new routes are pure SvelteKit SSR — no new services, no new containers, no new environment variables, no new infrastructure entries in the Compose file.
What I verified:
docker-compose.ymlordocs/DEPLOYMENT.mdchange needed.db-orm.pumlordb-relationships.pumlchange needed for this PR.playwright) need--project=clientfor component specs (fast, local-safe) and the full E2E suite for Playwright scenarios. Confirm the thin E2E scenarios don't require the full Docker Compose stack to be up — if they do, they must run in the CI e2e job, not the unit job. Based on the spec (curator logs in → creates event), they need the full stack.npm run generate:apiprerequisite (after #3 merges) is a build-time requirement documented in the issue. Make sure CI'sgenerate:apistep runs in the right job order — the issue already flags this dependency.No concerns from my angle — clean separation from infrastructure.
📋 Elicit — Requirements Engineer
Observations
All ACs are in Given-When-Then format — good. Three edge cases are underspecified:
Concurrent edit collision. What happens if curator A saves an event while curator B is editing the same event? The
TimelineEvententity has aversionfield (per the spec's audit section), suggesting optimistic locking. But the frontend issue doesn't mention handling a409 Conflictresponse. If the backend returns 409 on a stale version, the form action should surface it — but there's no AC for this. Either add an AC or explicitly note "optimistic locking conflict → generic error message, no special handling."Empty
titlewithtype="submit"and keyboard nav. The AC says "form shows a localized required-field error, does not navigate away." But there's no AC for what happens when the title field is blank ANDeventDateis also blank. The form has two required fields. The issue covers blank title alone, but does not specify whether both errors render simultaneously or sequentially. Spring Boot form actions returnfail(400)with a single error string currently — are we surfacing per-field errors or a single summary error? Make this explicit.Prefill with both
?personId=and?documentId=. The issue states the/newroute accepts both params. The spec says both are looked up in parallel viaPromise.all. If both are valid, both should be pre-selected. If one is 404/403, that one is silently ignored and the other is used. This is implied but not stated as an explicit AC. Add: Given?personId=A&documentId=Bwhere A is valid and B is 404, when/newloads, then person A is pre-selected and the document picker is empty.Scope boundary around
DatePrecisionField— cross-domain import concern. The issue explicitly placesDatePrecisionFieldin$lib/shared/primitives/to avoid cross-domain imports. ButDatePrecisionFieldneeds theDatePrecisiontype from$lib/shared/utils/documentDateand thePRECISIONSarray (currently defined inline inWhoWhenSection). The PRECISIONS array uses i18n keys from$lib/paraglide/messages.js. Moving it to$lib/shared/primitives/is fine — both of those imports are already inshared/. No cross-domain issue. However, the AC for the extraction should explicitly state which propsDatePrecisionFieldexposes, since this is now a shared contract between two consumers (WhoWhenSectionandEventForm). Consider adding a props interface to the task checklist.RANGEend-datenullon submit — the AC is precise but the test is not. The AC says "eventDateEnd is submitted asnull" butnullis a JSON concept. A form action receives''(empty string) from the hidden<input value="">. The mapping'' → nullhappens in the action. The testshould_submit_null_for_eventDateEnd_when_precision_changed_from_RANGE_to_YEARshould be a server spec (testing the form action), not a component spec — the component can only assert the hidden input's value is'', not that the API receivesnull. The issue lists this as a component test, which is the wrong layer.Recommendations
?personId=A&documentId=Bwhere A is valid and B does not exist, when the form loads, then person A is pre-selected and the document picker shows its empty state.should_submit_null_for_eventDateEnd_when_precision_changed_from_RANGE_to_YEARto the server spec layer. At the component layer, assert the hidden input value is''; at the server layer, assert the API body haseventDateEnd: null.Visual spec for the curator event editor (on
main, commitddb1ec4d):docs/specs/zeitstrahl-event-editor-spec.html— mirrorsGeschichteEditor(2fr/1fr, sticky save bar,beforeNavigateguard): title, Typ (PERSONAL/HISTORICAL segmented), Datum + Präzision (sharedDatePrecisionField, RANGE reveals end date), optional plain-text description; sidebar = Verknüpfte Briefe viaDocumentMultiSelect(the letter-grouping control, §5/⑤) + Beteiligte Personen viaPersonMultiSelect. Aligns with the decisions already in this body. Also covers the document-detail quick-action (Details-drawer "Zeitstrahl" column mirroring the Geschichten column + top-bar button) and the typeahead states — that surface isn't a separate issue yet, so flagging it here.