---
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
plan: 04
type: execute
wave: 4
depends_on: ["12.2-03"]
files_modified:
- 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
autonomous: false
requirements: [SC-1, SC-2, SC-3, SC-4]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 4
confidence: low
must_haves:
truths:
- "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: "Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected"
verification: backstop
- statement: "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"
verification: backstop
- statement: "Protected thumbnails are requested with X-Session-Key, rendered from an object URL and the URL is revoked on unmount"
verification: backstop
- statement: "Keyboard reorder: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one debounced reorder request"
verification: backstop
artifacts:
- path: "admin/src/app/sessionKey.ts"
provides: "newSessionKey, SESSION_HEADER, CHILD_SESSION_HEADER"
contains: "getRandomValues"
- path: "admin/src/api/files.ts"
provides: "FileRoutes adapters for parent and child file routes, XHR upload with progress"
contains: "XMLHttpRequest"
- path: "admin/src/components/form/fields/FileuploadField.vue"
provides: "fileupload control per UI-SPEC section 3"
- path: "admin/src/components/form/fields/DatepickerField.vue"
provides: "datepicker control per UI-SPEC section 1"
contains: "DatePickerRoot"
- path: "admin/src/components/relation/RelationChildModal.vue"
provides: "child create/update/preview modal"
- path: "admin/src/components/relation/RelationPivotModal.vue"
provides: "pivot edit modal"
- path: "modules/boardwalk/dist"
provides: "rebuilt embedded SPA"
key_links:
- from: "admin/src/views/FormView.vue"
to: "admin/src/app/sessionKey.ts"
via: "one key per mount, provided through FORM_SESSION and sent on save"
pattern: "newSessionKey"
- from: "admin/src/components/form/fields/FileuploadField.vue"
to: "admin/src/api/files.ts"
via: "upload/list/remove/caption/reorder/download/thumb through the injected FileRoutes"
pattern: "FORM_SESSION"
- from: "admin/src/components/relation/RelationManager.vue"
to: "admin/src/components/relation/RelationChildModal.vue"
via: "row click and the create button open the child modal with manageForm/viewForm"
pattern: "RelationChildModal"
prohibitions:
- statement: "New components MUST NOT render server or user strings (file names, captions, messages) through a raw-HTML sink; text interpolation only"
status: resolved
verification: test
- statement: "A session key MUST NOT be sent in a URL or query string"
status: resolved
verification: test
- statement: "DatepickerField MUST NOT use a native date input and MUST NOT construct a global Date for date-only values (D-21, Pitfall 12)"
status: resolved
verification: test
- statement: "No npm package other than @internationalized/date@3.12.4 is added, and it is added only after the user approves it"
status: resolved
verification: test
- statement: "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"
status: resolved
verification: 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.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.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 fileSPA-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.mdPer 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=1Any 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".
- `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.
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 dependencyAdd `@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..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 storedOne 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.shAny command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale".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.Keyboard flow, focus management and visual fit with Direction C cannot be fully asserted by unit tests.
- `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.
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 recordsSPA 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.tsPer 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-` 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=1Any 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.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.Pointer drag, progress feel and the end-to-end modal flow across saved and unsaved records need a person in the browser.
- `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.
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.
## 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). |
- `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).
- 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.