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

356 lines
43 KiB
Markdown

---
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.
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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
<interfaces>
- `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<boolean>`. 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.
</interfaces>
</context>
## 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).
<tasks>
<task type="tracer">
<name>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</name>
<reversibility rating="reversible">SPA-only code behind the existing build; the header names match plan 02/03 constants.</reversibility>
<files>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</files>
<read_first>.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</read_first>
<action>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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; go test ./cmd/summer -run TestDocsTree -count=1</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 2: Approve @internationalized/date@3.12.4 as a direct admin dependency</name>
<decision>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).</decision>
<context>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.</context>
<options>
<option id="approve">
<name>Approve the exact pin</name>
<pros>Explicit, auditable dependency; matches UI-SPEC and RESEARCH; no transitive-import fragility.</pros>
<cons>One more line in the 17-pin list.</cons>
</option>
<option id="transitive">
<name>Import it transitively without declaring it</name>
<pros>No package.json change.</pros>
<cons>Relies on npm hoisting; a reka-ui bump could move or remove it; vue-tsc and vite resolution become accidental.</cons>
</option>
<option id="refuse">
<name>Refuse</name>
<pros>No dependency change.</pros>
<cons>D-21 cannot be met with Reka DatePicker; the phase would need a new decision.</cons>
</option>
</options>
<read_first>.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</read_first>
<acceptance_criteria>
- 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).
</acceptance_criteria>
<resume-signal>Answer approve, transitive or refuse.</resume-signal>
</task>
<task type="auto">
<name>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</name>
<reversibility rating="reversible">One SPA control, a pure helper module and a list cell branch.</reversibility>
<precondition>The user approved option `approve` (or `transitive`) at Task 2.</precondition>
<files>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</files>
<read_first>.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)</read_first>
<action>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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh</automated>
<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>
<human-check>
<test>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.</test>
<expected>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.</expected>
<why_human>Keyboard flow, focus management and visual fit with Direction C cannot be fully asserted by unit tests.</why_human>
</human-check>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>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</name>
<reversibility rating="reversible">SPA components behind the existing relation manager; existing link/unlink visuals and copy are unchanged.</reversibility>
<files>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</files>
<read_first>.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</read_first>
<action>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.</action>
<verify>
<automated>npm --prefix admin run typecheck &amp;&amp; npm --prefix admin test &amp;&amp; go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v &amp;&amp; scripts/check-admin-dist.sh &amp;&amp; go test ./modules/boardwalk -count=1</automated>
<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>
<human-check>
<test>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.</test>
<expected>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.</expected>
<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>
</human-check>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `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).
</verification>
<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>
<output>
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-SUMMARY.md` when done.
</output>