Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-PLAN.md
Jakub Zych f4ec94531f docs(12.2): create phase plan
Five sequential plans: foundations (deferred_bindings, attach.Store,
lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation
child CRUD with deferral, admin SPA, and unit and security tests.
Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
2026-10-02 16:53:42 +02:00

43 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
12.2-admin-form-fields-date-file-upload-relation-editing-with-def 04 execute 4
12.2-03
admin/src/app/sessionKey.ts
admin/src/app/dateFormat.ts
admin/src/api/files.ts
admin/src/api/types.ts
admin/src/components/form/formContext.ts
admin/src/components/form/formState.ts
admin/src/components/form/registry.ts
admin/src/components/form/fields/FileuploadField.vue
admin/src/components/form/fields/FileCaptionModal.vue
admin/src/components/form/fields/DatepickerField.vue
admin/src/components/list/CellValue.vue
admin/src/components/relation/RelationManager.vue
admin/src/components/relation/RelationPickerModal.vue
admin/src/components/relation/RelationChildModal.vue
admin/src/components/relation/RelationPivotModal.vue
admin/src/views/FormView.vue
admin/tests/smoke/deferred.smoke.test.ts
admin/tests/fixtures/deferred.form-schema.json
admin/package.json
admin/package-lock.json
modules/phrasebook/backend/lang/en/lang.yaml
modules/phrasebook/backend/lang/pl/lang.yaml
modules/boardwalk/dist
docs/backend/admin-spa.md
false
SC-1
SC-2
SC-3
SC-4
tokens raw_tokens tasks confidence
240000 240000 4 low
truths artifacts key_links prohibitions
Per D-02, FormView generates one session key per mount (32 bytes from crypto.getRandomValues, base64url, 43 characters) and sends it as `X-Session-Key` on every upload, file list, file removal, deferred relation call and the final create or update save; a child modal generates its own key and sends it as `X-Child-Session-Key`; a key never appears in a URL.
Per D-03 and D-09, FileuploadField uploads each chosen file at once (deferred on the server), shows it in place, and marks the form dirty on upload, removal or a cancelled pending upload; reorder and caption edits are saved immediately and do not mark the form dirty; on an update form, files uploaded in this session carry the Unsaved chip.
Per D-08, the SPA pre-checks extension, maxFilesize and remaining maxFiles before sending (UX only); a file over size or of a wrong type becomes a Failed item and is never sent; files beyond maxFiles are dropped with one fileupload.too_many line; the server's 422 message is shown verbatim under the failed item.
Per D-10, protected (Public false) thumbnails and downloads are fetched through the admin API with the cookie and the session-key header, rendered from object URLs and revoked on unmount; public files use their public URLs.
Per D-18, `datetime` shows and edits the value in the browser time zone (parseAbsolute with getLocalTimeZone) and emits RFC 3339 UTC; `ignoreTimezone` emits the wall clock unchanged as YYYY-MM-DDTHH:MM:SSZ with no zone segment; `date` emits YYYY-MM-DD and `time` emits HH:MM:SS without conversion; an emptied optional field emits null.
Per D-20 and D-21, DatepickerField is built on Reka DatePickerRoot/DatePickerCalendar and TimeFieldRoot with @internationalized/date values: firstDay maps to weekStartsOn (locale default, Monday for pl), minDate/maxDate to minValue/maxValue, yearRange clamps only without explicit bounds, twelveHour to hourCycle 12, `displayFormat` drives the read-only and trigger text only; segment order follows the admin locale; native date inputs are not used.
Per D-11, D-12, D-14 and D-17, the relation manager renders the declared toolbarButtons in order with exactly one primary button, opens RelationChildModal (manageForm, or viewForm read-only) on row click per the UI-SPEC order, deletes selected children after a danger confirm, links with a pivot step (single select, then the pivotForm) when a pivot form exists, edits pivot values in RelationPivotModal, and shows datepicker and fileupload fields inside the child modal with the child's own key.
Per D-03, relation managers whose field says `deferrable: true` render on the create screen with the pending note under the header, call every relation route with record id 0 and the form's X-Session-Key, and mark the form dirty; on an update form relation changes are immediate and leave the form clean; a commit-time 422 on the relation-manager field renders on the normal field error line.
UI (covered): an empty DatepickerField shows the locale's placeholder segments; a read-only empty value shows a muted em dash; a 422 shows the FormField error line, a danger border and aria-invalid; an out-of-range or partly filled segment set keeps aria-invalid and the danger border and never emits a half value; a long time-zone segment truncates before the in-field buttons.
UI (covered): an empty editable FileuploadField is the dropzone with default_prompt/default_prompt_many (or the field's prompt) and the limits line; read-only shows fileupload.empty; the initial list load shows a 112px skeleton bar; a failed list load shows the role=alert block with fileupload.load_failed and hides the dropzone.
UI (covered): uploads render per item in place as queued, uploading (spinner, 4px progress bar, percentage), failed (server message for 422, too_large for 413, upload_failed plus Retry for network errors) or done; failed items never block Save; zero files shows only the dropzone, files show the grid or rows above it, and at maxFiles the dropzone hides and the limits line reads Files: max of max; names and captions truncate with the full value in title.
UI (covered): the child modal shows 6 skeleton field blocks while loading, an alert plus Cancel on load failure, a banner with field errors and focus on the first invalid field (switching tabs) on 422, and on 404 closes, shows relation.child_gone and reloads the list; its body scrolls inside max-h with header and footer fixed; a dirty child, pivot or caption modal asks form.unsaved_confirm before closing.
UI (covered): toolbar delete is disabled at zero selected, its confirm and toast use CLDR plural :count, its confirm button shows the busy state, and a failure keeps the selection with a danger toast; caption, pivot and link-step saves show form.saving with a disabled footer, 422 errors stay inside the dialog, other failures keep the input and toast form.error_generic, and a link-step 404 returns to step 1 with the list reloaded.
UI (covered): list cells `type: date` and `type: time` render the stored string (time without seconds) in the datetime cell style and a muted em dash when empty, never through the global Date constructor.
statement verification
Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected backstop
statement verification
Datetime round trip in a fixed non-UTC zone shows the local wall clock and emits the UTC string; ignoreTimezone emits the wall clock unchanged backstop
statement verification
Protected thumbnails are requested with X-Session-Key, rendered from an object URL and the URL is revoked on unmount backstop
statement verification
Keyboard reorder: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one debounced reorder request backstop
path provides contains
admin/src/app/sessionKey.ts newSessionKey, SESSION_HEADER, CHILD_SESSION_HEADER getRandomValues
path provides contains
admin/src/api/files.ts FileRoutes adapters for parent and child file routes, XHR upload with progress XMLHttpRequest
path provides
admin/src/components/form/fields/FileuploadField.vue fileupload control per UI-SPEC section 3
path provides contains
admin/src/components/form/fields/DatepickerField.vue datepicker control per UI-SPEC section 1 DatePickerRoot
path provides
admin/src/components/relation/RelationChildModal.vue child create/update/preview modal
path provides
admin/src/components/relation/RelationPivotModal.vue pivot edit modal
path provides
modules/boardwalk/dist rebuilt embedded SPA
from to via pattern
admin/src/views/FormView.vue admin/src/app/sessionKey.ts one key per mount, provided through FORM_SESSION and sent on save newSessionKey
from to via pattern
admin/src/components/form/fields/FileuploadField.vue admin/src/api/files.ts upload/list/remove/caption/reorder/download/thumb through the injected FileRoutes FORM_SESSION
from to via pattern
admin/src/components/relation/RelationManager.vue admin/src/components/relation/RelationChildModal.vue row click and the create button open the child modal with manageForm/viewForm RelationChildModal
statement status verification
New components MUST NOT render server or user strings (file names, captions, messages) through a raw-HTML sink; text interpolation only resolved test
statement status verification
A session key MUST NOT be sent in a URL or query string resolved test
statement status verification
DatepickerField MUST NOT use a native date input and MUST NOT construct a global Date for date-only values (D-21, Pitfall 12) resolved test
statement status verification
No npm package other than @internationalized/date@3.12.4 is added, and it is added only after the user approves it resolved test
statement status verification
No new design token, colour, font or radius is introduced (UI-SPEC); hard-coded hex values are limited to the inherited #e0b020 selected-option border resolved test

Phase Goal

ROADMAP Phase 12.2 goal (verbatim): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it.

This plan's slice: everything the administrator sees. After it an admin can pick dates, upload, reorder, caption and remove files, and create, edit, delete, link and pivot-edit related records in modals, on new and saved records, in the embedded SPA.

Build the admin SPA (summercms.go `admin/`) side of Phase 12.2 on the routes and generated types of plans 02 and 03, following 12.2-UI-SPEC.md exactly, add the SPA strings to the backend lang catalogs, and rebuild the committed `modules/boardwalk/dist`.

Purpose: success criteria 1-4 become usable by an administrator; plan 05 adds the SPA unit tests and backstops. Output: session key helper, file route adapters, DatepickerField, FileuploadField, caption modal, relation child and pivot modals, create-screen deferral, date/time list cells, lang keys, rebuilt dist, admin-spa docs note.

Repo: summercms.go only. Neutral names in fixtures (acme). Code and planning docs in separate commits; no co-author tags. Every task rebuilds modules/boardwalk/dist with npm --prefix admin run build and commits it with its source so scripts/check-admin-dist.sh stays clean at every commit.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md @.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md @.planning/phases/10-admin-vue-spa/design/README.md @admin/src/views/FormView.vue @admin/src/components/relation/RelationManager.vue - `admin/src/api/client.ts`: `api` (openapi-fetch typed by `paths` from schema.d.ts), the `transport` middleware sets `X-Requested-With: XMLHttpRequest` and replays once after a shared `refreshSession()` on 401; `REQUESTED_WITH`; `refreshSession(): Promise`. openapi-fetch calls accept `headers` per request. - `admin/src/api/types.ts`: aliases over `components['schemas']` (FormView, FormField, RelationSchema, RecordEnvelope, AdminRecord, ErrorEnvelope, ...). Plans 02/03 add `cabana.FileItem`, `cabana.FileMutationResult`, `cabana.AdminFileCaptionRequest`, `cabana.AdminRelationLinkRequest` and new FormField/RelationSchema keys (`mode`, `displayFormat`, `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour`, `ignoreTimezone`, `fileTypes`, `mimeTypes`, `maxFilesize`, `maxFiles`, `imageWidth`, `imageHeight`, `thumbOptions`, `useCaption`, `prompt`, `multiple`, `protected`, `deferrable`; RelationSchema `kind`, `deferrable`, `manageForm`, `viewForm`, `pivotForm`, new messages keys). - `admin/src/components/form/control.ts`: `FieldControlProps{field, modelValue, controlId, invalid?, describedBy?, labels?, source?, recordId?}`, `controlClass(invalid)`, `controlAttributes(field)`. - `admin/src/components/form/formContext.ts`: injection keys `FORM_VALUES`, `FORM_PATCH`, `FORM_LOCALE`, `FORM_ASSETS`. - `admin/src/components/form/registry.ts`: `renderers` map, `selfLabelled`, `recordBound` (relation-manager), `valueless` (relation-manager, widget, partial), `groupLabelledTypes`, `rendererFor`, `isRegistered`, `needsRecord`, `ownsLabel`, `groupLabelled`. - `admin/src/components/form/FormGrid.vue` props: `fields, values, errors, labels?, source?, recordId?, idPrefix?`; emits `update(name, value)`. FormErrorBanner, FormTabs, FormField reused unchanged in modals. - `admin/src/views/FormView.vue`: `mode` create/update, `recordId`, `fields` computed drops `needsRecord` types on create, `dirty` from `snapshot(editablePayload(...))`, save via `api.POST/PUT`, ConfirmDialog/useConfirm dirty guard, `showToast`. - `admin/src/components/relation/RelationManager.vue` / `RelationPickerModal.vue`: Reka Dialog parts, generation counter for stale responses, toolbar from `schema.view.toolbarButtons`, `pathParams()` with `id: props.recordId`. - `admin/src/components/list/CellValue.vue`: `kind` switch (switch, datetime, text), `datetime()` uses the global Date (keep it for datetime only). - i18n: `t(key)`, `tc`, `message(forms, ...)` from `admin/src/app/i18n.ts`; every `backend::lang.*` literal in admin/src must resolve in pl and en (`TestPhase10SPAKeysResolve` in modules/phrasebook). - Reka 2.9.10 exports used: DatePickerRoot, DatePickerField, DatePickerInput, DatePickerTrigger, DatePickerContent, DatePickerCalendar, DatePickerHeader, DatePickerPrev, DatePickerHeading, DatePickerNext, DatePickerGrid, DatePickerGridHead, DatePickerGridBody, DatePickerGridRow, DatePickerHeadCell, DatePickerCell, DatePickerCellTrigger, TimeFieldRoot, TimeFieldInput, Dialog*, ProgressRoot, ProgressIndicator; `@lucide/vue` icons listed in UI-SPEC.

Artifacts this phase produces

(This plan's share.)

  • admin/src/app/sessionKey.ts: newSessionKey(): string, SESSION_HEADER = 'X-Session-Key', CHILD_SESSION_HEADER = 'X-Child-Session-Key'.
  • admin/src/app/dateFormat.ts: parseFieldValue(mode, raw, ignoreTimezone), emitFieldValue(mode, value, ignoreTimezone), formatDisplay(value, mode, displayFormat, locale), weekStart(locale, firstDay).
  • admin/src/api/files.ts: FileRoutes interface (list, upload, update, remove, reorder, downloadUrl/download, thumb) with parentFileRoutes(...) and childFileRoutes(...), uploadWithProgress(...) on XMLHttpRequest.
  • admin/src/components/form/formContext.ts: FORM_SESSION injection key and FormSession type (key, child key, file routes factory, record id with 0 for unsaved, markDirty(), pendingChanges counter).
  • Components: DatepickerField.vue, FileuploadField.vue, FileCaptionModal.vue, RelationChildModal.vue, RelationPivotModal.vue; registry entries datepicker, fileupload.
  • Lang keys (en, pl): backend::lang.datepicker.*, backend::lang.fileupload.*, backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note} with UI-SPEC copy.
  • Direct npm dependency @internationalized/date 3.12.4 (exact pin, after approval).

Planner assumptions recorded for this plan

  • Upload progress needs XMLHttpRequest (fetch has no upload progress); the XHR sends X-Requested-With, X-Session-Key (and the child key in a child modal), uses same-origin credentials, and on a 401 calls refreshSession() once and retries.
  • The caption dialog is a sibling component, FileCaptionModal.vue (UI-SPEC planner's choice).
  • Manual checks (keyboard and a11y of the calendar, drag reorder and progress feel) are human-check items harvested at the end of the phase (workflow.human_verify_mode default end-of-phase).
Task 1: On a new record the admin drops an image into a fileupload field, sees it upload, saves, and the saved record shows the file SPA-only code behind the existing build; the header names match plan 02/03 constants. admin/src/app/sessionKey.ts, admin/src/api/files.ts, admin/src/api/types.ts, admin/src/components/form/formContext.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/FileuploadField.vue, admin/src/components/form/fields/FileCaptionModal.vue, admin/src/views/FormView.vue, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist, docs/backend/admin-spa.md .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Copywriting Contract; Component Contracts sections 3 and 5; UI Considerations), admin/src/api/client.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts (FileItem and the /files paths), admin/src/components/form/control.ts, admin/src/components/form/formContext.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/WidgetField.vue (group-labelled control), admin/src/components/relation/RelationPickerModal.vue (Dialog shell), admin/src/components/ui/Button.vue, admin/src/views/FormView.vue, admin/src/styles/main.css (tokens), admin/tests/helpers.ts, admin/tests/smoke/tracer.smoke.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, docs/backend/admin-spa.md Per D-02, D-03, D-08, D-09 and D-10 and UI-SPEC sections 3 and 5.

(1) sessionKey.ts: newSessionKey() fills 32 bytes with crypto.getRandomValues and encodes them base64url without padding (43 characters, matching the server pattern); header constants as in Artifacts.

(2) api/files.ts: a FileRoutes interface and two factories. parentFileRoutes(source, recordId (0 when unsaved), field, sessionKey) wraps the typed api calls for GET/POST /{vendor}/{plugin}/{controller}/{id}/files/{field}, PUT/DELETE .../{file}, POST .../reorder, GET .../{file}/download and .../thumb, always passing the X-Session-Key header (never a query parameter). childFileRoutes(...) targets the /relations/{name}/records/{child}/files/{field} routes and passes both headers (used in Task 4). uploadWithProgress posts multipart file_data with XMLHttpRequest (credentials same-origin, X-Requested-With, the session headers), reports progress events, supports abort, retries once after refreshSession() on 401, and resolves to the FileItem or a typed error (422 message text, 413, network). Protected thumbnail and download use fetch with the same headers and return a Blob for an object URL.

(3) formContext.ts: FORM_SESSION (FormSession: the key, an optional child key, routes(field) returning FileRoutes, the record id with 0 for unsaved, markDirty() and a reactive pendingChanges count). FormView creates one key on mount, provides FORM_SESSION with parentFileRoutes, adds pendingChanges > 0 to dirty, and sends X-Session-Key on the create POST and update PUT (openapi-fetch headers). A 422 whose details name a fileupload or relation-manager field lands on that field through the existing fieldErrors mapping and counts in the banner.

(4) registry.ts and formState.ts: register fileupload as FileuploadField; it is valueless (never in the save body) and group-labelled (its label is a span the control's role=group points at). Leave the relation-manager rules for Task 4.

(5) FileuploadField.vue exactly per UI-SPEC section 3: dropzone (button, hidden file input with accept from fileTypes/mimeTypes or image defaults, multiple for attachMany, drag-over styling on the zone only), image grid tiles for attachMany image mode, the attachOne image row with Replace and Remove, file-mode rows with Download, Pencil (useCaption) and X, the Unsaved chip on update forms for pending items, per-item states (queued, uploading with ProgressRoot, failed with the server message, too_large, upload_failed plus retry, done), uploads one at a time in selection order, client pre-checks (extension, maxFilesize, remaining maxFiles with the single too_many line), deferred remove (no dialog; the item leaves at once, pending uploads are cancelled), protected thumbnails and downloads from object URLs revoked on unmount (public files use url/thumb_url), the 112px loading bar and the load_failed alert, read-only rendering, reorder by pointer drag and by keyboard (ArrowUp/ArrowLeft earlier, ArrowDown/ArrowRight later, focus stays on the handle, polite aria-live announcing fileupload.moved, one request debounced 400ms, rollback plus reorder_failed toast on failure). FileCaptionModal.vue per the caption modal contract (Reka Dialog, 560px, Title input and Description textarea, Cancel and details_submit, immediate save, details_saved toast, 422 inside the modal). Use only existing tokens and component classes; text interpolation only (no raw-HTML directive). Every string through t/tc/message.

(6) Lang: add every backend::lang.fileupload.* key of the UI-SPEC "All new keys" list to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with the UI-SPEC English and Polish texts (CLDR maps for too_many: en one/other, pl one/few/many/other).

(7) Smoke test admin/tests/smoke/deferred.smoke.test.ts with fixture admin/tests/fixtures/deferred.form-schema.json (an acme form with a photos fileupload field, image mode, attachMany): mount FormView in create mode with a mocked XHR and fetch; choose a PNG; assert the upload request targets /files/photos with record id 0 and carries X-Session-Key, the form becomes dirty, and the create POST carries the same key.

(8) docs/backend/admin-spa.md "How the SPA talks to the server": one paragraph on the session-key headers and deferred uploads. Rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the source. npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1 <fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found" or the summary lacks the deferred smoke file as passed; TestPhase10SPAKeysResolve prints a line ending in "does not resolve"; check-admin-dist.sh prints "modules/boardwalk/dist is stale".</fails_when> <acceptance_criteria> - grep -c 'getRandomValues' admin/src/app/sessionKey.ts prints at least 1 and grep -c 'XMLHttpRequest' admin/src/api/files.ts prints at least 1. - grep -rc 'session_key=' admin/src | awk -F: '{s+=$2} END {print s}' prints 0. - grep -c 'v-html' admin/src/components/form/fields/FileuploadField.vue prints 0 and grep -c 'v-html' admin/src/components/form/fields/FileCaptionModal.vue prints 0. - grep -c "\['fileupload', FileuploadField\]" admin/src/components/form/registry.ts prints 1. - grep -c 'default_prompt_many' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. - npm --prefix admin test -- tests/smoke/deferred reports the smoke file passed. </acceptance_criteria> An administrator can attach files to a record before its first save; the upload, the pending state and the commit at Save all work through the real API contract.

Task 2: Approve @internationalized/date@3.12.4 as a direct admin dependency Add `@internationalized/date` 3.12.4 to admin/package.json as an exact-pinned direct dependency (the Phase 10 npm gate requires a checkpoint for any new package). Reka's DatePicker and TimeField bind `DateValue`/`TimeValue` objects from @internationalized/date (D-21). The package is already in admin/node_modules as a reka-ui dependency (`"@internationalized/date":"^3.5.0"`), so declaring it adds no new code to the tree; it makes the import explicit instead of relying on hoisting. RESEARCH Package Legitimacy Audit: npm, ~19.0M weekly downloads, source github.com/adobe/react-spectrum (packages/@internationalized/date), latest 3.12.4 published 2026-09-01, `postinstall: null`, verdict [OK]. Verify at https://www.npmjs.com/package/@internationalized/date before approving. Undo cost: one dependency line and a lockfile update. Approve the exact pin Explicit, auditable dependency; matches UI-SPEC and RESEARCH; no transitive-import fragility. One more line in the 17-pin list. Import it transitively without declaring it No package.json change. Relies on npm hoisting; a reka-ui bump could move or remove it; vue-tsc and vite resolution become accidental. Refuse No dependency change. D-21 cannot be met with Reka DatePicker; the phase would need a new decision. .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Standard Stack, Package Legitimacy Audit), admin/package.json, admin/node_modules/@internationalized/date/package.json - The user answered with one of the option ids; the answer is recorded in the plan summary. - Nothing was installed before the answer (`git diff --stat HEAD -- admin/package.json` is empty when the checkpoint is presented). Answer approve, transitive or refuse. Task 3: The admin picks a date, a datetime in local time or a time of day, and lists show date and time columns as stored One SPA control, a pure helper module and a list cell branch. The user approved option `approve` (or `transitive`) at Task 2. admin/package.json, admin/package-lock.json, admin/src/app/dateFormat.ts, admin/src/components/form/fields/DatepickerField.vue, admin/src/components/form/registry.ts, admin/src/components/list/CellValue.vue, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Component Contracts sections 1 and 2), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Reka UI props and the Winter key to Reka prop mapping; Pitfalls 12, 13), admin/node_modules/reka-ui/dist/index4.d.ts (DatePickerRootProps, DateFieldRootProps, TimeFieldRootProps), admin/src/components/form/control.ts, admin/src/components/form/fields/TextField.vue, admin/src/components/list/CellValue.vue, admin/src/app/i18n.ts (currentLocale) Per D-18, D-19, D-20 and D-21 and UI-SPEC sections 1 and 2.

(1) Dependency: with approve, run npm --prefix admin install --save-exact @internationalized/date@3.12.4 and commit package.json and package-lock.json with this task; with transitive, import it without changing package.json and note that in the summary.

(2) dateFormat.ts (pure, unit-testable): parseFieldValue (date: parseDate; datetime: parseAbsolute(value, getLocalTimeZone()), or with ignoreTimezone a CalendarDateTime from the UTC wall clock; time: parseTime; null or empty gives null), emitFieldValue (date YYYY-MM-DD; datetime toAbsoluteString() in UTC; ignoreTimezone YYYY-MM-DDTHH:MM:SSZ from the wall clock; time HH:MM:SS; cleared gives null), formatDisplay (the server's displayFormat tokens DD, D, MM, M, YYYY, YY, HH, H, hh, h, mm, ss, A, a, day and month names in the admin locale; defaults YYYY-MM-DD, YYYY-MM-DD HH:mm, HH:mm), weekStart(locale, firstDay) (firstDay when set, else the locale's first day, Monday for pl). Never construct a global Date for date-only values.

(3) DatepickerField.vue per UI-SPEC section 1: date and datetime modes on DatePickerRoot with DatePickerField/Input segments, the time-zone segment for datetime without ignoreTimezone, the clear button (optional, has value, not read-only) and the calendar trigger (32x32, aria labels datepicker.clear and datepicker.open_calendar), the calendar popover (284px, header with prev/next buttons labelled datepicker.prev_month and next_month, day cell states, closeOnSelect, Esc returns focus to the trigger, picking a day keeps the time or sets 00:00); time mode on TimeFieldRoot with the Clock icon; mapping minDate/maxDate to minValue/maxValue, yearRange clamping without explicit bounds, twelveHour to hourCycle 12, granularity minute for datetime; :focus-within ring, focused segment styling, invalid and data-invalid danger border with aria-invalid; read-only box with formatDisplay or a muted em dash; label and aria-describedby wiring via controlId. Native date inputs are not used. Register datepicker in registry.ts as a value field.

(4) CellValue.vue: kinds date (string as stored) and time (HH:mm, seconds dropped) with the datetime cell classes and the muted em dash when empty, never through the global Date constructor.

(5) Lang: backend::lang.datepicker.* keys (open_calendar, clear, prev_month, next_month) in en and pl with the UI-SPEC texts.

(6) Extend the smoke fixture and test: a datepicker starts_at (datetime) and released_on (date) field; with the process time zone fixed to Europe/Warsaw, a stored 2026-10-02T10:30:00Z shows 12:30 and saving it unchanged sends the same UTC string; the date field sends 2026-10-02. Rebuild and commit modules/boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh <fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale".</fails_when> Run go run ./cmd/summer serve against a development app with an acme form that has date, datetime and time datepicker fields; open the create screen; Tab into each field, type a value by keyboard, open the calendar with Enter, move with arrow keys, PageUp/PageDown, pick a day, press Esc. Segments follow the admin locale, the focused segment uses the sel tint, the calendar is navigable by keyboard, Esc returns focus to the trigger, a day outside minDate/maxDate cannot be picked, the datetime shows local time with its zone segment, and the visuals match the UI-SPEC and Direction C. <why_human>Keyboard flow, focus management and visual fit with Direction C cannot be fully asserted by unit tests.</why_human> <acceptance_criteria> - grep -c '"@internationalized/date": "3.12.4"' admin/package.json prints 1 when the user chose approve. - grep -c 'DatePickerRoot' admin/src/components/form/fields/DatepickerField.vue prints at least 1 and grep -c 'TimeFieldRoot' admin/src/components/form/fields/DatepickerField.vue prints at least 1. - grep -c 'type="date"' admin/src/components/form/fields/DatepickerField.vue prints 0 and grep -c 'new Date(' admin/src/components/form/fields/DatepickerField.vue prints 0. - grep -c "'date'" admin/src/components/list/CellValue.vue prints at least 1. - grep -c 'open_calendar' modules/phrasebook/backend/lang/pl/lang.yaml prints 1. </acceptance_criteria> Date, datetime and time fields are edited with an accessible, locale-aware picker that converts only datetime values, and lists show stored dates and times without shifting them.

Task 4: The admin creates, edits, deletes, links and pivot-edits related records in modals, with uploads and dates inside them, on new and saved records SPA components behind the existing relation manager; existing link/unlink visuals and copy are unchanged. admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/relation/RelationPivotModal.vue, admin/src/components/form/registry.ts, admin/src/views/FormView.vue, admin/src/api/types.ts, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Component Contracts section 4; Destructive actions; Success feedback; UI Considerations rows for the child modal, toolbar, pivot and caption modals), admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/list/DataTable.vue (variant relation, selection), admin/src/components/form/FormGrid.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/FormErrorBanner.vue, admin/src/components/ui/ConfirmDialog.vue, admin/src/components/ui/confirm.ts, admin/src/views/FormView.vue, admin/src/api/schema.d.ts (relation routes, RelationSchema), admin/tests/relation/RelationManager.test.ts, admin/tests/relation/RelationPickerModal.test.ts Per D-03, D-11, D-12, D-14 and D-17 and UI-SPEC section 4.

(1) RelationManager.vue: toolbar buttons in declared order, all size sm, exactly one primary (create primary; link primary only without create; delete and unlink danger); row selection checkboxes when delete or unlink is declared; delete selected through ConfirmDialog (messages.relation.delete_selected, delete_confirm, deleted with CLDR :count) calling POST .../relations/{name}/delete; row click order (update: child modal in update mode; else belongsToMany with pivotForm: pivot modal; else viewForm: read-only child modal; else not clickable), the first cell a button, the trailing SlidersHorizontal pivot button when update is declared on a belongsToMany with a pivot form. On the create screen (FORM_SESSION record id 0) it uses id 0 and the X-Session-Key header on every call, shows the relation.pending_note line under the header, and calls markDirty() after each change; on an update form changes are immediate and the form stays clean.

(2) RelationChildModal.vue: Reka Dialog per the UI-SPEC child modal (720px, header with create_title/update_title/preview_title and the close button, body with FormErrorBanner, FormTabs when the child fields declare tabs, FormGrid with idPrefix child-<relation> over manageForm or viewForm fields), footer (danger Delete on the left in update mode when delete is declared, ghost Cancel and the primary create_submit/update_submit on the right, form.saving while busy), skeleton loading, load failure alert, 422 banner and focus on the first invalid field, 404 closes with the child_gone toast and reloads, success closes with created/updated toasts, dirty close asks form.unsaved_confirm, focus returns to the create button or the row button. It generates its own key on open and provides its own FORM_SESSION with childFileRoutes (child id 0 on create) so FileuploadField and DatepickerField work inside it; the save sends X-Session-Key (the parent form's key) and X-Child-Session-Key.

(3) Pivot: RelationPickerModal switches to single select with a Next step when the relation has a pivotForm (step 2 shows the chosen record and a FormGrid of pivotForm, Back keeps search, page and selection, link_submit posts {ids:[id], pivot:{...}}); RelationPivotModal.vue edits pivot values (560px, pivot_title, the related record's name, FormGrid, Cancel and pivot_submit, pivot_saved toast) through GET/PUT .../relations/{name}/pivot/{child}.

(4) FormView and registry: on create, keep relation-manager fields whose deferrable is true (others are dropped as today); everything else of the existing needsRecord rule stays.

(5) Lang: backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note} in en and pl with the UI-SPEC texts. Text interpolation only; existing tokens only.

(6) Extend the smoke test: on the create screen a deferrable hasMany manager posts .../{0}/relations/parts/records with X-Session-Key, the form becomes dirty, and the parent create POST carries the same key. Update existing RelationManager and RelationPickerModal tests only where the new toolbar or single-select behaviour changes their expectations (keep every existing assertion that still describes unchanged behaviour). Rebuild and commit modules/boardwalk/dist. npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1 <fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale"; the boardwalk embed test fails.</fails_when> With the development app, open the create screen of an acme controller that has a deferrable hasMany relation manager (with a fileupload and a datepicker in its manage form) and a belongsToMany manager with a pivot form; create two children (one with an uploaded image), link a record with pivot details, upload several images to an attachMany field and drag them into a new order, then Save; reopen the saved record and edit a child, edit pivot details, delete a child. Children, links, pivot values and files appear after the first save; upload progress and drag reorder feel smooth; each modal matches the UI-SPEC (single primary button, danger delete on the left, focus handling, toasts); nothing appears for another parent. <why_human>Pointer drag, progress feel and the end-to-end modal flow across saved and unsaved records need a person in the browser.</why_human> <acceptance_criteria> - grep -c 'RelationChildModal' admin/src/components/relation/RelationManager.vue prints at least 1 and grep -c 'RelationPivotModal' admin/src/components/relation/RelationManager.vue prints at least 1. - grep -c 'CHILD_SESSION_HEADER\|X-Child-Session-Key' admin/src/components/relation/RelationChildModal.vue admin/src/api/files.ts | awk -F: '{s+=$2} END {print s}' prints at least 1. - grep -c 'v-html' admin/src/components/relation/RelationChildModal.vue prints 0 and grep -c 'v-html' admin/src/components/relation/RelationPivotModal.vue prints 0. - grep -c 'deferrable' admin/src/views/FormView.vue prints at least 1. - grep -c 'pending_note' modules/phrasebook/backend/lang/en/lang.yaml prints 1. </acceptance_criteria> The relation manager offers Winter's create, update, delete, link, unlink and pivot editing in modals on new and saved records, and the administrator sees the same behaviour the server enforces.

<threat_model>

Trust Boundaries

Boundary Description
Server data (file names, captions, messages, record values) → DOM Untrusted strings rendered in the admin's browser
SPA → admin API Session keys and CSRF header on every write; cookie auth
npm registry → admin/package.json A dependency enters the embedded build

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-12.2-29 Spoofing session key generation medium mitigate 32 bytes from crypto.getRandomValues per form mount and per child modal; never Math.random (Task 1).
T-12.2-30 Elevation of Privilege XSS via file names, captions, server messages high mitigate Text interpolation only in every new component; acceptance greps for raw-HTML sinks (Tasks 1, 4).
T-12.2-31 Information Disclosure protected thumbnail object URLs low mitigate Fetched with cookie and session header, revoked on unmount; never a public URL for protected rows (Task 1).
T-12.2-32 Tampering CSRF on XHR uploads high mitigate uploadWithProgress always sets X-Requested-With; the server's requireAjax refuses cookie writes without it (Task 1).
T-12.2-33 Information Disclosure session key in URLs or logs low mitigate Headers only; acceptance grep for a key query parameter (Task 1).
T-12.2-SC Tampering npm install of @internationalized/date high mitigate blocking-human decision checkpoint before install; exact pin 3.12.4; legitimacy audit OK in RESEARCH; package-lock integrity hash committed (Tasks 2, 3).
</threat_model>
- `npm --prefix admin run typecheck && npm --prefix admin test` green; `go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1` green; `scripts/check-admin-dist.sh` clean after every task commit; `go test ./modules/boardwalk -count=1` green. - `go vet ./... && go test -short ./...` still green (the lang.yaml edits load in Go).

<success_criteria>

  • DatepickerField, FileuploadField, the caption modal, the child and pivot modals and create-screen deferral match 12.2-UI-SPEC.md and the D-02, D-03, D-08 to D-12, D-14, D-17, D-18, D-20 and D-21 contracts.
  • Every new string resolves in pl and en; the embedded dist matches a fresh build; the only new npm dependency is the approved exact pin. </success_criteria>
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-SUMMARY.md` when done.