50 KiB
phase, slug, status, reviewed_at, shadcn_initialized, preset, created
| phase | slug | status | reviewed_at | shadcn_initialized | preset | created |
|---|---|---|---|---|---|---|
| 12.2 | admin-form-fields-date-file-upload-relation-editing-with-def | approved | 2026-10-02 | false | none | 2026-10-02 |
Phase 12.2 — UI Design Contract
Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
This phase adds three controls to the existing admin SPA (admin/): DatepickerField, FileuploadField, and child create/update/delete plus pivot editing in the relation manager. The SPA already has a complete, high-fidelity design system: Direction C v2 (.planning/phases/10-admin-vue-spa/design/README.md), implemented as Tailwind 4 @theme tokens in admin/src/styles/main.css, built on Reka UI primitives. This contract adds no new tokens, colours, fonts, or radii. Every value below either reuses an existing token or names an existing component class string. Where Direction C already fixes a value (control heights, modal styling, focus ring), it is restated here as binding, not redesigned.
Sources: CONTEXT.md D-01..D-21 (locked), RESEARCH.md (Reka 2.9.10 props, @internationalized/date 3.12.4, route table, assumptions A7/A10/A11), Direction C README (tokens and screens), and the codebase (main.css, Button.vue, control.ts, RelationPickerModal.vue, ConfirmDialog.vue, FormGrid.vue, FormField.vue, CellValue.vue, modules/phrasebook/backend/lang/{en,pl}/lang.yaml, modules/cabana/messages.go).
Design System
| Property | Value |
|---|---|
| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system; shadcn gate not applicable to a Vue codebase with an established system) |
| Preset | not applicable |
| Component library | Reka UI 2.9.10 (headless primitives; the Vue port of Radix) plus the local generic components in admin/src/components/ |
| Icon library | @lucide/vue 1.17.0 (named imports only, 16px in buttons, 14px in error lines, 18–20px in empty/drop areas) |
| Font | DM Sans 400/500/600/700 (self-hosted @fontsource/dm-sans); DM Mono 400/500 for code tokens; tabular-nums for every date, time, size and counter |
| Date library | @internationalized/date 3.12.4 (new direct dependency, authorized by RESEARCH.md, legitimacy audit OK) |
Component Inventory
Enumerated by node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)" (run in admin/) — 478 exports — reka-ui@2.9.10 — 2026-10-02.
Local components enumerated by find admin/src/components -name '*.vue' | wc -l — 33 components — summercms-admin@unversioned (private workspace package with no version field in admin/package.json; pinned by repo commit 3ac1d64) — 2026-10-02.
This table is a non-exhaustive list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path.
| Component | Import path | Notes |
|---|---|---|
| DatePickerRoot, DatePickerField, DatePickerInput, DatePickerTrigger, DatePickerContent, DatePickerCalendar, DatePickerHeader, DatePickerPrev, DatePickerHeading, DatePickerNext, DatePickerGrid, DatePickerGridHead, DatePickerGridBody, DatePickerGridRow, DatePickerHeadCell, DatePickerCell, DatePickerCellTrigger | reka-ui |
mode: date and mode: datetime (D-21). Verified exported. |
| TimeFieldRoot, TimeFieldInput | reka-ui |
mode: time. Verified exported. |
| DialogRoot, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose | reka-ui |
Child modal, pivot modal, caption modal. Same parts as RelationPickerModal.vue. |
| AlertDialog* | reka-ui via components/ui/ConfirmDialog.vue |
Every destructive confirmation. Do not build a second confirm. |
| ProgressRoot, ProgressIndicator | reka-ui |
Upload progress bar. Verified exported. |
CalendarDate, CalendarDateTime, ZonedDateTime, Time, parseDate, parseAbsolute, parseTime, getLocalTimeZone |
@internationalized/date |
v-model values for Reka (RESEARCH mapping table). Never new Date() for date-only values. |
| Button | admin/src/components/ui/Button.vue |
Variants primary/outline/ghost/danger, sizes md (42px) / sm (38px). Every new text button uses it. |
ConfirmDialog + useConfirm |
admin/src/components/ui/ConfirmDialog.vue, confirm.ts |
Destructive and unsaved-changes confirms. |
| FormGrid, FormField, FormTabs, FormErrorBanner | admin/src/components/form/ |
Reused unchanged inside the child and pivot modals. |
DataTable (variant="relation"), Pagination |
admin/src/components/list/ |
Relation list; row click added (see contract). |
| RelationManager, RelationPickerModal | admin/src/components/relation/ |
Extended, not forked. |
controlClass, controlAttributes |
admin/src/components/form/control.ts |
Border/background/padding of every new input surface. |
showToast |
admin/src/state/useToasts.ts |
Success (status) and failure (danger, alert) toasts. |
t, tc, message |
admin/src/app/i18n.ts |
All copy. No literal UI strings in components. |
| Icons used by this phase | @lucide/vue |
Calendar, Clock, X, ChevronLeft, ChevronRight, Upload, FileText, Image, ImageOff, GripVertical, Pencil, Download, LoaderCircle, RotateCcw, CircleAlert, Plus, Trash2, SlidersHorizontal, Info (all verified present in 1.17.0). Existing UserPlus/UserMinus on link/unlink stay unchanged. |
New components this phase creates (names from RESEARCH.md): fields/DatepickerField.vue, fields/FileuploadField.vue, relation/RelationChildModal.vue, relation/RelationPivotModal.vue, app/sessionKey.ts, app/dateFormat.ts. The caption dialog lives inside FileuploadField.vue (or a sibling FileCaptionModal.vue, planner's choice).
Spacing Scale
Declared values for new layout in this phase (multiples of 4):
| Token | Value | Tailwind | Usage |
|---|---|---|---|
| xs | 4px | gap-1 |
Icon-button clusters on file tiles, segment gaps in date fields |
| sm | 8px | gap-2, p-2 |
File-row stack gap, tile meta padding, dropzone inner gap, action inset on tiles (top-2 right-2) |
| md | 16px | gap-4, p-4 |
File tile grid gap, calendar popover padding, modal section gap, gap between dropzone and file list |
| lg | 24px | p-6, gap-6 |
Modal padding (child, pivot, caption), dropzone vertical padding |
| xl | 32px | px-8 |
Inherited page footer padding (unchanged) |
| 2xl | 48px | — | Not used by new components |
| 3xl | 64px | — | Not used by new components |
Fixed component dimensions (all multiples of 4): calendar day cell 36×36; calendar popover content width 284 (7×36 + 2×16); in-field icon buttons 32×32; file row height 56; file tile minimum width 160 with a square thumbnail; dropzone minimum height 112; upload progress bar height 4; child modal max width 720; pivot and caption modal max width 560.
Exceptions (inherited from Direction C, binding, not new): form-grid row gap 22px (gap-y-[22px]); control horizontal padding 14px (px-3.5); label-to-control gap 6px (gap-1.5); footer button gap 10px (gap-2.5); control heights 44px input / 42px button / 38px small button / 34px modal close and pager buttons; relation rows 56px; picker option rows 54px; error banner padding 14×18.
Added exception (12px, gap-3 / px-3): the file row (flex h-14 items-center gap-3 px-3) and the child modal header (flex items-start gap-3). Reason: these are row and header siblings of existing Direction C components that already use 12px. The picker option rows use gap-3 px-3, and the picker and relation-manager headers use gap-3. Matching them keeps a file row aligned with a candidate row and the child modal header aligned with the picker header. 12px is still a multiple of 4. No other new 12px spacing is allowed.
Intentional fixed dimensions off the 4/8/16/24/32 spacing scale. These are sizes, not spacing, and are listed so the checker and auditor treat them as deliberate:
- 36px calendar day cells and the 36px calendar header row (Reka grid, 7 columns);
- 284px popover width (7×36 + 2×16);
- 40px dropzone icon circle;
- 160px tile minimum and attachOne tile;
- 64px caption preview thumbnail;
- 112px dropzone minimum height;
- 12px-tall skeleton label bar with
rounded-[6px](the existing list skeleton bar); rounded-[4px]focused date segment (smaller thanrounded-pagerso it fits a 2-digit segment);- 4px progress bar;
- 1.5px dashed dropzone border (the existing UnsupportedField border).
Typography
New components use exactly three sizes. All inherit DM Sans and the 14px/1.5 base from html in main.css.
| Role | Size | Weight | Line Height | Used by |
|---|---|---|---|---|
| Body | 14px | 400 | 1.5 | Date segments, calendar day numbers, dropzone prompt, file names in rows, modal body text |
| Label | 14px | 600 | 1.5 | Field labels (existing FormField), file name on attachOne, calendar month heading, primary/secondary button text (existing Button) |
| Meta | 13px | 400 | 1.5 | Limits line, file size, upload percentage, per-file errors, weekday header, pending-change note, time-zone segment, read-only date text in tiles |
| Heading | 20px | 700 | 1.2 (tracking-[-0.01em]) |
Modal titles (child, pivot, caption): the existing DialogTitle class from RelationPickerModal.vue |
Weights: this phase introduces content at 400 and 600 only. 700 is the inherited, heading-only weight. It appears on modal titles and nowhere else in new components, and is the Direction C modal title style and must be copied verbatim (text-[20px] font-bold tracking-[-0.01em]), not varied. The "Unsaved" chip uses 13px/600. No 12px, 17px, 24px or 26px text is introduced by this phase. Every date, time, byte size, percentage and counter uses tabular-nums.
Color
All colours are existing CSS variables; light / dark values from main.css. Hard-coded hex values are forbidden in new components (the single inherited exception is #e0b020, the selected-option border already used by RelationPickerModal.vue, reused for a selected candidate in the single-select pivot picker).
| Role | Value (light / dark) | Usage |
|---|---|---|
| Dominant (60%) | bg #f4f6f9 / #111726, surface #ffffff / #182033 |
Page background; inputs, popovers, modals, file tiles and rows |
| Secondary (30%) | subtle #f3f5f8 / #1f283d, border #e6e9ef / #29334b, border-strong #d2d8e2 / #3a4661, muted #566175 / #a9b3c6 |
Dropzone fill, thumbnail placeholder, file-type icon square, tile and row borders, input borders, dashed dropzone border, meta text |
| Accent (10%) | primary #22304d / #fcd34d (action colour); brand accent #fcd34d with ring rgba(252,196,40,.55) / rgba(252,211,77,.45) and sel #fdf3cf / #3a3622 |
See reserved list below |
| Destructive | danger #c62828 / #f58a8a, danger-soft #fdf0f0 / #3a1d24 |
Remove/delete buttons (danger outline), delete confirm button, invalid input borders, per-file error rows, error banners |
Accent reserved for (exhaustive for this phase):
primaryfill: the single primary button of each surface (the modal*_submitlabels "Create record", "Save record", "Save link details", "Add link" and "Save details", the relation toolbar's first primary action), the selected calendar day, and the upload progress indicator.ring(3px): focus-visible on every new focusable element and:focus-withinon the date field container. Never removed.seltint: the focused date segment, the dropzone while a file is dragged over it, the reorder drop slot, and the "Unsaved" chip.primaryborder: the dropzone border while dragging over it, and input borders on focus (existing base style).
Accent is never used for: links inside text, icons, file names, calendar "today" marker (uses border-strong), hover states (use hover), or secondary buttons.
Copywriting Contract
All copy is keyed in modules/phrasebook/backend/lang/{en,pl}/lang.yaml (framework strings) or in cabana's per-relation messages block (overridable in config_relation.yaml, defaulting to backend::lang.messages.relation.*). Winter key names are kept where Winter has one (fileupload.*). Plural texts are CLDR maps (one, few, many, other for Polish). Placeholders use :name syntax. Polish is the design language; English values follow.
Primary CTAs and key states
| Element | Copy (EN / PL) |
|---|---|
| Primary CTA, child modal (create) | messages.relation.create_submit "Create record" / "Utwórz rekord" |
| Primary CTA, child modal (update) | messages.relation.update_submit "Save record" / "Zapisz rekord" |
| Primary CTA, pivot modal (edit later) | messages.relation.pivot_submit "Save link details" / "Zapisz szczegóły powiązania" |
| Primary CTA, pivot link step | messages.relation.link_submit "Add link" / "Dodaj powiązanie" |
| Primary CTA, caption modal | fileupload.details_submit "Save details" / "Zapisz szczegóły" |
The four messages.relation.*_submit keys can be overridden per relation in config_relation.yaml, for example "Create track". The main form footer keeps the existing form.save / form.save_and_close (unchanged). While a primary action is busy, every one of these buttons shows the existing form.saving.
| Relation toolbar create button | messages.relation.create "New record" / "Nowy rekord" (Plus icon) |
| Empty state heading (file field, editable) | the dropzone prompt itself: fileupload.default_prompt "Drag a file here or click to upload" / "Przeciągnij plik tutaj lub kliknij, aby przesłać"; attachMany: fileupload.default_prompt_many "Drag files here or click to upload" / "Przeciągnij pliki tutaj lub kliknij, aby przesłać". A field's prompt: key replaces this text. |
| Empty state body (file field, editable) | the limits line, built from the field config: fileupload.limits_types "Allowed: :types" / "Dozwolone: :types", fileupload.limits_size "up to :size" / "do :size", fileupload.limits_count "Files: :current of :max" / "Pliki: :current z :max", joined with " · " |
| Empty state (file field, read-only) | fileupload.empty "No files" / "Brak plików" |
| Empty state (relation list) | existing messages.relation.empty "No linked records." / "Brak powiązanych rekordów." (unchanged) |
| Error state (upload failed, network) | fileupload.upload_failed "The file could not be uploaded. Try again." / "Nie udało się przesłać pliku. Spróbuj ponownie." + fileupload.retry button "Retry" / "Ponów" |
| Error state (file too large, client pre-check or 413) | fileupload.too_large ":name is larger than :size. Choose a smaller file." / "Plik :name jest większy niż :size. Wybierz mniejszy plik." |
| Error state (wrong type, client pre-check) | fileupload.wrong_type ":name is not an allowed file type. Allowed: :types." / "Plik :name ma niedozwolony typ. Dozwolone: :types." |
| Error state (over maxFiles) | fileupload.too_many one: "File limit reached (:max). :count file was not added." other: "File limit reached (:max). :count files were not added." / one: "Osiągnięto limit plików (:max). Nie dodano :count pliku." few/many/other: "Osiągnięto limit plików (:max). Nie dodano :count plików." |
| Error state (server 422 on upload) | the server message, verbatim, under the failed item (never re-worded in the SPA) |
| Error state (file list load failed) | fileupload.load_failed "The files could not be loaded. Refresh the page to try again." / "Nie udało się wczytać plików. Odśwież stronę, aby spróbować ponownie." |
| Error state (reorder failed) | fileupload.reorder_failed "The new order could not be saved. The previous order is back." / "Nie udało się zapisać nowej kolejności. Przywrócono poprzednią." (danger toast) |
| Error state (child form load failed) | relation.child_load_failed "The record could not be loaded. Close this window and try again." / "Nie udało się wczytać rekordu. Zamknij to okno i spróbuj ponownie." |
| Error state (child gone / 404) | relation.child_gone "This record no longer exists. The list has been refreshed." / "Ten rekord już nie istnieje. Lista została odświeżona." (danger toast) |
| Error state (child save 422) | existing form.error_title + form.error_fields banner inside the modal ("Could not save. Fix :count fields marked below." / "Nie udało się zapisać. Popraw :count pola oznaczone poniżej.") |
| Error state (child save, other failure) | existing form.error_generic "The changes could not be saved. Please try again." / "Nie udało się zapisać zmian. Spróbuj ponownie." |
Destructive actions
| Action | Confirmation approach | Copy (EN / PL) |
|---|---|---|
| Remove a file (saved or pending) | No dialog. Deferred until the parent's Save (D-03); Cancel on the form undoes it. The form becomes dirty, so leaving triggers the existing unsaved-changes confirm. | aria-label via existing form.remove_item "Remove: :name" / "Usuń: :name" |
Delete selected children (toolbar, delete button) |
ConfirmDialog, danger |
Button: messages.relation.delete_selected "Delete selected" / "Usuń zaznaczone". Confirm: messages.relation.delete_confirm "Delete the selected (:count)? This cannot be undone." / "Usunąć zaznaczone (:count)? Tej operacji nie można cofnąć." Confirm button label: "Delete selected" / "Usuń zaznaczone". |
| Delete one child (child modal footer) | ConfirmDialog, danger, rendered above the child modal |
messages.relation.delete_one_confirm "Delete this record? This cannot be undone." / "Usunąć ten rekord? Tej operacji nie można cofnąć." Confirm button: form.delete "Delete" / "Usuń". |
| Unlink selected | Existing ConfirmDialog (unchanged) | existing messages.relation.unlink_confirm |
| Close a dirty child, pivot or caption modal | ConfirmDialog, danger, rendered above the modal |
existing form.unsaved_confirm "You have unsaved changes. Discard them?" / "Masz niezapisane zmiany. Porzucić je?"; confirm button existing form.discard "Discard changes" / "Porzuć zmiany" |
| Cancel an in-flight upload | No dialog (nothing is stored yet) | aria-label fileupload.cancel_upload "Cancel upload: :name" / "Anuluj przesyłanie: :name" |
On a saved parent, child delete and unlink are immediate (D-03). On an unsaved parent they are deferred; the same confirms apply.
Success feedback (toasts, role="status", existing Toast)
| Event | Copy (EN / PL) |
|---|---|
| Child created | messages.relation.created "Record created" / "Utworzono rekord" |
| Child saved | messages.relation.updated "Record saved" / "Zapisano rekord" |
| Children deleted | messages.relation.deleted one: "Deleted :count record" other: "Deleted :count records" / one: "Usunięto :count rekord" few: "Usunięto :count rekordy" many: "Usunięto :count rekordów" other: "Usunięto :count rekordu" |
| Linked with pivot | existing messages.relation.linked |
| Pivot details saved | messages.relation.pivot_saved "Link details saved" / "Zapisano szczegóły powiązania" |
| Caption saved | fileupload.details_saved "Attachment details saved" / "Zapisano szczegóły załącznika" |
| File upload finished | no toast (the item changes state in place) |
All new keys (complete list for the planner)
backend::lang.datepicker.*: open_calendar "Open calendar" / "Otwórz kalendarz"; clear "Clear value" / "Wyczyść wartość"; prev_month "Previous month" / "Poprzedni miesiąc"; next_month "Next month" / "Następny miesiąc".
backend::lang.fileupload.* (Winter names kept: attachment, help, title_label, description_label, default_prompt, upload_file, upload_error, remove_file): attachment "Attachment details" / "Szczegóły załącznika" (caption modal title); help "Add a title and description for this attachment." / "Dodaj tytuł oraz opis załącznika."; title_label "Title" / "Tytuł"; description_label "Description" / "Opis"; default_prompt; default_prompt_many; upload_file "Upload file" / "Prześlij plik"; replace_file "Replace file" / "Zamień plik"; remove_file "Remove file" / "Usuń plik"; upload_error "Upload error" / "Błąd przesyłania" (visually hidden prefix of every per-file error for screen readers); upload_failed; retry; too_large; wrong_type; too_many; limits_types; limits_size; limits_count; uploading "Uploading… :percent%" / "Przesyłanie… :percent%"; queued "Waiting…" / "Oczekuje…"; unsaved "Unsaved" / "Niezapisany"; move "Move: :name" / "Przenieś: :name"; moved "Moved :name to position :position of :total." / "Przeniesiono :name na pozycję :position z :total."; reorder_failed; edit_details "Edit details: :name" / "Edytuj szczegóły: :name"; download "Download: :name" / "Pobierz: :name"; open_preview "Open: :name" / "Otwórz: :name"; preview_unavailable "No preview" / "Brak podglądu"; load_failed "The files could not be loaded. Refresh the page to try again." / "Nie udało się wczytać plików. Odśwież stronę, aby spróbować ponownie."; cancel_upload; details_saved; details_submit "Save details" / "Zapisz szczegóły"; empty.
backend::lang.relation.* (framework, not overridable): child_load_failed; child_gone; next "Next" / "Dalej"; back "Back" / "Wstecz"; pending_note "Changes in this section are saved together with the record." / "Zmiany w tej sekcji zostaną zapisane razem z rekordem."
backend::lang.messages.relation.* (new defaults; new keys on cabana's relationMessageKeys / RelationMessages, overridable per relation): create "New record" / "Nowy rekord"; create_title "New record" / "Nowy rekord"; update_title "Edit record" / "Edycja rekordu"; preview_title "Record preview" / "Podgląd rekordu"; created; updated; delete_selected; delete_confirm; delete_one_confirm; deleted; pivot_title "Link details" / "Szczegóły powiązania"; pivot_saved; edit_pivot "Edit link details: :name" / "Edytuj szczegóły powiązania: :name"; create_submit "Create record" / "Utwórz rekord"; update_submit "Save record" / "Zapisz rekord"; pivot_submit "Save link details" / "Zapisz szczegóły powiązania"; link_submit "Add link" / "Dodaj powiązanie". YAML spellings follow the existing camelCase block (createTitle, createSubmit, deleteConfirm, …); the backend::lang keys use snake_case like the existing ones.
Component Contracts
1. DatepickerField (type: datepicker)
Structure (date and datetime modes). One field container styled with controlClass(invalid) plus h-input flex items-center gap-1 pl-3.5 pr-1.5. Inside, left to right: the Reka DatePickerInput segments (14px/400, tabular-nums); for datetime without ignoreTimezone, Reka's time-zone segment (13px muted, not editable); a flexible spacer; the clear button (only when the field is not required, has a value, and is not read-only); the calendar trigger. Both in-field buttons are 32×32, rounded-pager (8px), Calendar/X icon 16px muted, hover:bg-hover hover:text-text, aria-labels datepicker.open_calendar and datepicker.clear.
Time mode. TimeFieldRoot in the same container. A non-interactive Clock icon (16px, muted, aria-hidden) sits where the calendar trigger sits. No popover. twelveHour: true adds the AM/PM segment (hourCycle: 12), otherwise 24-hour.
Segments. Placeholder segments use text-placeholder. A focused segment gets bg-sel text-text rounded-[4px] and no per-segment outline. The container shows the input focus treatment through :focus-within (border primary plus the 3px ring shadow), matching every other input. Segment order follows the admin locale (currentLocale); format never reorders segments (Pitfall 13).
Calendar popover. DatePickerContent with bg-surface border border-border rounded-inner shadow-menu p-4, width 284, offset 4px below the field, aligned to the field's end. Header row 36px: ChevronLeft/ChevronRight 32×32 rounded-pager buttons (aria-labels datepicker.prev_month/next_month) around the month-and-year heading (14px/600, locale month name, first letter upper-cased). Weekday head cells 13px muted, 36px wide. Day cells 36×36, rounded-pager, 14px/400 tabular-nums:
| Day state | Style |
|---|---|
| Default | text-text, hover:bg-hover |
| Outside the visible month | text-placeholder |
| Today | inset 1px border-strong ring, weight 600 |
| Selected | bg-primary text-on-primary, weight 600 |
Disabled (outside minDate/maxDate) |
text-placeholder opacity-40 cursor-not-allowed, not focusable by click |
| Keyboard-focused | 3px ring outline (base style) |
closeOnSelect is on. Esc closes and returns focus to the trigger. In datetime mode the time is edited in the field's hour/minute segments; picking a day keeps the current time, or sets 00:00 when the value was empty.
Value contract (D-18, D-19). date emits YYYY-MM-DD and never converts. datetime shows the value in the browser time zone (parseAbsolute(iso, getLocalTimeZone())) and emits an RFC 3339 UTC string (toAbsoluteString()). datetime + ignoreTimezone emits the wall clock unchanged as YYYY-MM-DDTHH:MM:SSZ, with no zone segment shown. time emits HH:MM:SS. An emptied optional field emits null.
Options mapping. firstDay maps to weekStartsOn; when absent, use the locale's first day of the week (Monday for pl). minDate/maxDate map to minValue/maxValue (the server re-checks). yearRange clamps minValue/maxValue only when no explicit min/max is set. twelveHour maps to hourCycle.
Read-only (attributes: readonly: true). Render the formatted text in the existing read-only box (flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5). Display format: the compiled format tokens, else YYYY-MM-DD (date), YYYY-MM-DD HH:mm (datetime), HH:mm (time), the same shapes as the list's datetime cell. An empty value shows a muted "—".
Invalid. A 422 error renders through the existing FormField error line. The container border becomes danger and the focusable segment group gets aria-invalid="true". A segment value Reka flags as out of range (data-invalid) uses the same danger border before submit.
A11y. The FormField label targets the first segment's id (controlId), and aria-describedby is forwarded to the segment group. The calendar grid keeps Reka's roles and arrow, PageUp and PageDown navigation. Both in-field buttons are reachable with Tab after the segments.
2. List cells type: date / type: time (only if the plan adds them, RESEARCH Pitfall 12)
These render the stored string as-is, never through new Date(): date as YYYY-MM-DD, time as HH:mm (seconds dropped). They use the existing datetime cell style, text-[13px] text-muted tabular-nums. Empty shows the muted "—".
3. FileuploadField (type: fileupload)
Label. fileupload joins groupLabelledTypes in registry.ts. The label is a <span> that the control's role="group" names through aria-labelledby. The required asterisk works as on every field.
Visual anchor. On an empty field, the eye lands first on the dropzone: the field's largest element and the only dashed surface on the form. Once files exist, the first tile's thumbnail (image mode) or the first row's file name (file mode) leads, and the dropzone follows below as the secondary "add more" surface. Only the drag-over state uses accent colour, never the idle dropzone.
Dropzone (shown when another file may be added: attachOne with no file, or attachMany below maxFiles; hidden when read-only).
- A full-width
<button type="button">withmin-h-[112px] w-full flex flex-col items-center justify-center gap-2 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-4 py-6 text-center transition-colors duration-150 ease-out hover:bg-hover. It holds a 40pxbg-surfacecircle with theUploadicon (20px, muted), the prompt line (14px/400,text-text), and the limits line (13px muted,tabular-nums). - Clicking it, or pressing Enter or Space, opens the hidden
<input type="file">. That input hasacceptbuilt fromfileTypes/mimeTypes(orimage/*style defaults formode: image) andmultiplefor attachMany. - While a file is dragged over it:
border-solid border-primary bg-sel. Drops on the rest of the page are ignored (preventDefaulton the zone only). - The limits line is omitted when the field declares none of
fileTypes,mimeTypes,maxFilesize,maxFiles.
Image mode, attachMany: tile grid. grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4, placed 16px above the dropzone (the grid comes first, the dropzone last).
- Tile:
relative overflow-hidden rounded-inner border border-border bg-surface. - Thumbnail area:
aspect-square bg-subtle. The<img>usesobject-coverforthumbOptions.modecrop/exactandobject-containforfit/auto, withalt= the caption title or the file name. - Thumbnails are requested at
imageWidth×imageHeight, else 240×240 (A11). - Meta row:
p-2, file name 13px truncate (titleattribute holds the full name), size 13px mutedtabular-nums(1.2 MB, decimal units, one decimal). - Overlay actions: top-right at
top-2 right-2,flex gap-1. Each is a 32×32rounded-pager bg-surface border border-border text-muted hover:text-texticon button:Pencil(only withuseCaption, ariafileupload.edit_details) andX(ariaform.remove_item). TheXturnstext-danger hover:bg-danger-softon hover. - The drag handle is a 32×32
GripVerticalbutton attop-2 left-2with the same style (ariafileupload.move). - All overlay buttons are always visible, never hover-only.
- Clicking the thumbnail opens the original in a new tab (aria
fileupload.open_preview).
Image mode, attachOne. One row, flex gap-4 items-start: a 160×160 tile (no overlay actions), then a column with the file name (14px/600, truncate), size (13px muted), and a flex gap-2 row holding Button size="sm" variant="outline" :icon="Upload" labelled fileupload.replace_file and Button size="sm" variant="danger" :icon="X" labelled fileupload.remove_file. Replace opens the file dialog. The new file is uploaded; the old one is unbound at Save (Winter attachOne semantics).
File mode (attachOne and attachMany): row list. flex flex-col gap-2. Each row is flex h-14 items-center gap-3 rounded-inner border border-border bg-surface px-3:
GripVerticalhandle 32×32 (attachMany only);- a 32×32
rounded-pager bg-subtlesquare holdingFileText16px muted, or a 32×32object-coverthumbnail when the file is an image; - a name column: caption title (14px/600) above the file name (13px muted) when
useCaptionhas a title, else the file name alone at 14px/400, truncated; - size, 13px muted
tabular-nums; - actions:
Download(ariafileupload.download),Pencil(withuseCaption), andX, each 32×32 ghost icon buttons.
attachOne in file mode shows the same single row plus a Replace file small outline button before the actions.
"Unsaved" chip. On an update form only, files uploaded in this session carry a chip: inline-flex h-6 items-center rounded-pill bg-sel px-2 text-[13px] font-semibold text-text. It sits in the tile meta row or after the row's size. On a create form every file is pending, so no chip is shown.
Upload states, per item, in place.
| State | Rendering |
|---|---|
| Queued | Item shell with file name, fileupload.queued 13px muted. Uploads run one at a time in selection order. |
| Uploading | Thumbnail area (or icon square) shows LoaderCircle 20px muted with animate-spin. Below the name: ProgressRoot 4px rounded-pill bg-subtle, ProgressIndicator bg-primary, plus fileupload.uploading 13px muted tabular-nums. Progress comes from upload progress events. The X button aborts (aria fileupload.cancel_upload). |
| Failed | Item border border-danger, CircleAlert 16px danger. The error text is 13px text-danger: the server message for a 422, too_large for 413, upload_failed for network errors, prefixed by visually hidden fileupload.upload_error. Network failures add a RotateCcw retry icon button. X dismisses the item. Failed items are never sent at Save. |
| Done | Normal tile or row. |
Client pre-checks (UX only; the server enforces D-08). Before upload, every chosen file is checked against the extension list, maxFilesize and remaining maxFiles. A file over size or with the wrong type becomes a Failed item and is never sent. Files beyond maxFiles are dropped, and a single fileupload.too_many line (13px danger with CircleAlert 14px, role="alert") shows under the dropzone until the next selection.
Thumbnails for protected files (D-10). For Public: false relations, thumbnails and downloads go through the admin API with cookie auth and the X-Session-Key header. They are fetched with fetch, rendered from an object URL, and the object URL is revoked on unmount. Public files use their public URL directly. A thumbnail that fails to load shows ImageOff 20px muted centred in the bg-subtle area with fileupload.preview_unavailable 13px muted.
Reorder (attachMany, D-09; immediate per A7).
- Pointer: drag by the handle. The dragged item is
opacity-50, and the target slot shows aborder-2 border-dashed border-primary bg-selplaceholder. - Keyboard: focus the handle; ArrowUp/ArrowLeft moves the item one place earlier, ArrowDown/ArrowRight one place later. Focus stays on the moved item's handle, and a polite
aria-liveregion announcesfileupload.moved. - On drop or key move, send the full order once. On failure, restore the previous order and show the
reorder_faileddanger toast. - Moving an item is debounced 400ms, so repeated arrow presses send one request.
Remove (deferred, D-03). The item leaves the list immediately and the form becomes dirty. Removing a pending upload cancels it. No confirm (see Destructive actions).
Caption modal (useCaption). Reka Dialog, max-w-[560px] rounded-modal bg-surface p-6 gap-4 shadow-pop, same overlay and close button as the picker.
- Title:
fileupload.attachment. Description:fileupload.help. - A preview strip: 64×64
rounded-innerthumbnail or icon square, plus the file name 13px muted. - Fields: Title (44px text input, label
title_label) and Description (textarea, 3 rows, labeldescription_label), both styled withcontrolClass. - Footer: two equal-width buttons,
Cancel(outline) andfileupload.details_submit"Save details" (primary). Saving applies immediately (A7), closes the modal, updates the item and shows thedetails_savedtoast. A 422 shows field errors inside the modal.
Read-only. No dropzone, handles, remove or caption buttons. Download/open remain. With no files, show fileupload.empty in the read-only box style.
Dirty tracking. Any upload, removal or cancelled pending upload marks the parent form dirty. Reorder and caption edits do not, because they are already saved.
4. Relation manager: child CRUD, pivot, deferral
Toolbar (D-12). Buttons render in the declared toolbarButtons order, all size="sm" (38px). Each toolbar has exactly one primary button.
| Button | Variant | Icon | Label | Enabled when |
|---|---|---|---|---|
create |
primary |
Plus |
messages.relation.create |
always (not busy) |
link |
primary if no create is declared, else outline |
UserPlus (unchanged) |
existing messages.relation.link |
schema loaded |
delete |
danger |
Trash2 |
messages.relation.delete_selected |
1+ rows selected |
unlink |
danger (unchanged) |
UserMinus (unchanged) |
existing | 1+ rows selected |
update |
— (no toolbar button) | — | — | enables row editing |
Row selection checkboxes show when delete or unlink is declared.
Row click (deterministic order).
updatedeclared: open the child modal in update mode.- Else belongsToMany with
pivot.form: open the pivot modal. - Else
view.formdeclared: open the child modal read-only (titlemessages.relation.preview_title, footer shows onlyClose). - Else rows are not clickable.
Clickable rows get cursor-pointer hover:bg-hover. The first cell's text is a <button> (keyboard focus, Enter opens). When update is declared and the relation is belongsToMany with pivot.form, a trailing 56px cell holds a 32×32 ghost SlidersHorizontal icon button (aria messages.relation.edit_pivot) that opens the pivot modal. Checkbox and button clicks never trigger the row action.
Child modal (RelationChildModal.vue, D-11, D-17).
- Visual anchor: the 20/700 modal title is read first, then the primary submit button, the only
primaryfill in the modal, at the bottom right. The form grid sits between them with no other emphasis. The Delete button stays a danger outline on the far left, so it never competes with the submit button. - Reka Dialog over the existing overlay:
DialogContentwithfixed top-1/2 left-1/2 z-50 flex max-h-[calc(100vh-32px)] w-[calc(100%-32px)] max-w-[720px] -translate-x-1/2 -translate-y-1/2 flex-col rounded-modal bg-surface text-text shadow-popand the existing 200ms fade/scale. - Header (
p-6 pb-4,flex items-start gap-3): title,messages.relation.create_title,update_titleorpreview_title(20/700, inherited class), and the 34px close button copied from the picker. - Body (
flex-1 min-h-0 overflow-y-auto px-6):FormErrorBannerwhen there are errors,FormTabswhen the child fields declare tabs, thenFormGridwithidPrefix="child-<relation>",source= the parent controller, and the record id of the child (or null on create). - Footer (
flex items-center gap-2.5 border-t border-border px-6 py-4): on the left,Button variant="danger" :icon="Trash2"labelledform.delete, only in update mode whendeleteis declared. On the right (ml-auto flex gap-2.5):Button variant="ghost"form.cancel, thenButton variant="primary"withmessages.relation.create_submit(create) ormessages.relation.update_submit(update). While saving, the primary button showsform.savingand every control is disabled. - Loading: the body shows 6 skeleton field blocks in the grid. Each block is a label bar (12px tall, 96px wide,
rounded-[6px] bg-skel) above a 44pxrounded-control bg-skelbar. - Load failure: the body shows the alert block (
rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger,role="alert") withrelation.child_load_failed; the footer keeps onlyCancel. - 422: banner plus field errors, focus moves to the first invalid field, switching tabs when needed. The modal stays open.
- 404 on load, save or delete: close the modal, show the
relation.child_gonedanger toast, reload the list. - Success: close the modal, reload the list, toast
createdorupdated. - Focus: on open, the first focusable field. On close, focus returns to the
createbutton or to the row's first-cell button. - Esc, overlay click, the close button and Cancel all close. When the child form is dirty, the unsaved-changes ConfirmDialog asks first.
- Session key: the modal generates its own key on open (
sessionKey.ts) and sends it on child file operations and the child save (D-17). - Allowed field types inside the modal follow the server's compiled schema. A child form never contains a relation manager (boot error, D-17).
Pivot link flow (belongsToMany with pivot.form, D-14). RelationPickerModal switches to single-select: aria-multiselectable="false", the option checkbox becomes a radio-style mark, and the selected option keeps border-[#e0b020] bg-sel. The footer primary reads relation.next and is disabled until one row is chosen; Enter on an option also advances. Step 2 replaces the list in the same dialog:
- title
messages.relation.pivot_title; - the chosen record's name (14/600) above its secondary text (13 muted);
- a
FormGridbuilt frompivot.form; - footer with
relation.back(outline, returns to the list keeping search, page and selection) andmessages.relation.link_submit(primary).
On success the picker closes, the list reloads, and the toast reads linked with count 1. Without pivot.form, the picker is unchanged.
Pivot modal (RelationPivotModal.vue, edit later). Same shell as the caption modal (max-w-[560px], p-6, gap-4): title pivot_title, the related record's name as description, a FormGrid built from pivot.form, and two equal-width footer buttons, Cancel (outline) and messages.relation.pivot_submit "Save link details" (primary). The success toast reads pivot_saved. Server-owned pivot columns never appear (whitelist, D-14).
Unsaved parent (create screen, D-03).
- Relation managers whose schema says
deferrable: truerender on create; non-deferrable ones stay hidden, as today (needsRecord). - On create, a note sits directly under the section header, 8px above the table:
flex items-center gap-2 text-[13px] text-muted,Info14px, textrelation.pending_note. - All list, create, link, unlink and delete calls use record id
0with the form'sX-Session-Key. - On create, every change made in the manager marks the parent form dirty. On update, relation changes are immediate and do not mark it dirty.
- A commit-time eligibility failure (RESEARCH Pitfall 14) arrives as a 422 on the relation-manager field name and renders through the normal field error line under the section.
Unchanged. The existing link picker, unlink flow, search, sort, pagination, 56px rows and the existing message keys keep their current visuals and copy.
5. Form view and session key
FormViewgenerates one session key when the view mounts (one per route instance; the view is keyed by path). It is 32 random bytes fromcrypto.getRandomValues, base64url-encoded.- The key is sent as
X-Session-Keyon every upload, file list, file removal, deferred relation call and the final save. - After a successful create, the existing navigation to the update route mounts a new view with a fresh key.
- No new visible element. The existing sticky footer, error banner and toasts cover save outcomes.
- The 422 banner count includes fileupload
required/maxFileserrors and relation-manager commit errors, which land on their field names. - "Save and close" or navigating away with pending uploads triggers no extra prompt beyond the existing dirty guard (
form.unsaved_confirm).
UI Considerations
Applicable state considerations resolved (probe: 8 elements, 53 element×state pairs): every pair is covered, backstopped or dismissed with a reason; 0 unresolved. Rows group pairs that share a resolution.
| Category | Element(s) | Status | Resolution / Reason |
|---|---|---|---|
| empty | DatepickerField (form) | ✅ covered | An empty field shows locale placeholder segments in text-placeholder; a read-only empty value shows a muted "—" |
| loading | DatepickerField | ✅ covered | No async load: the value comes with the record; nothing to show |
| error | DatepickerField | ✅ covered | A 422 renders through the FormField error line with a danger border and aria-invalid; out-of-range segments get a danger border before submit |
| long-text | DatepickerField | ✅ covered | Fixed-length segments; a long time-zone segment truncates inside the 44px container before the in-field buttons |
| empty | FileuploadField (form, media) | ✅ covered | The dropzone with the default_prompt/default_prompt_many copy and the limits line is the empty state; read-only shows fileupload.empty |
| loading | FileuploadField file list | ✅ covered | While the initial file list loads, show a 112px bg-skel rounded-control bar in place of the dropzone and list |
| loading | FileuploadField uploads | ✅ covered | Queued, uploading (spinner plus 4px progress bar plus percentage) and done states render in place per item |
| error | FileuploadField uploads | ✅ covered | Per-item Failed state using the Copywriting Contract error rows, with retry for network errors and dismiss |
| error | FileuploadField list load | ✅ covered | If the file list fails to load, an alert block (rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger, role="alert") shows fileupload.load_failed; the dropzone stays hidden until reload |
| populated | FileuploadField (image grid) | ✅ covered | Square 160px-min tiles in an auto-fill grid with a meta row and always-visible actions |
| zero-one-many | FileuploadField attachMany | ✅ covered | Zero: dropzone only; one or more: grid or list above the dropzone; at maxFiles: dropzone hidden, limits line shows "Files: max of max" under the list |
| overflow | FileuploadField list | ✅ covered | The grid wraps to new rows; the page scrolls (no inner scroll); file names truncate with the full name in title |
| long-text | File names and captions | ✅ covered | Single-line truncate in tiles and rows; the full value is in title and in the caption modal |
| partial | FileuploadField mixed batch | ✅ covered | In a batch with some failed and some uploaded files, each item keeps its own state; failed items never block Save and are not sent |
| empty | Relation list | ✅ covered | The existing messages.relation.empty copy (unchanged) |
| loading | Relation child modal | ✅ covered | 6 skeleton field blocks in the grid; footer buttons disabled |
| error | Relation child modal | ✅ covered | Load failure: alert block plus Cancel; 422: banner plus field errors plus focus on the first invalid field; 404: close, danger toast child_gone, list reloads |
| error | Relation toolbar delete | ✅ covered | A failed delete shows the server message (or form.error_generic) as a danger toast; the selection is kept |
| partial | Child form with tabs | ✅ covered | Invalid fields on another tab mark that tab with the existing error count badge; the first invalid field is focused after switching |
| long-text | Child modal content | ✅ covered | The body scrolls inside max-h-[calc(100vh-32px)] while header and footer stay fixed |
| zero-one-many | Relation toolbar delete | ✅ covered | Disabled at zero selected; the confirm and toast use CLDR plural forms of :count |
| loading | Pivot link step | ✅ covered | The Add button shows the busy state and is disabled while posting; Back is disabled too |
| error | Calendar popover keyboard and SR flow | 🧪 backstop | An SPA unit test asserts that focus returns to the trigger on Esc and that a disabled day cannot be selected |
| error | Datetime time-zone round trip | 🧪 backstop | An SPA unit test with a fixed non-UTC zone asserts the displayed local wall clock and the emitted UTC string, and that ignoreTimezone emits an unchanged wall clock |
| populated | Protected thumbnails | 🧪 backstop | An SPA unit test asserts the protected thumbnail request carries X-Session-Key, renders from an object URL, and revokes it on unmount |
| overflow | Reorder by keyboard | 🧪 backstop | An SPA unit test asserts that ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved, and sends one debounced reorder request |
| empty / zero-one-many | List cells date/time |
✅ covered | Empty value shows the muted "—"; one value per cell, so count does not vary (Component Contracts §2) |
| loading / error / populated / partial / overflow | List cells date/time |
➖ dismissed | Cells render a stored string synchronously inside the existing list; list loading and error states belong to the unchanged list view, and fixed-length values cannot overflow |
| partial | DatepickerField | ✅ covered | A partly filled segment set is invalid: the field keeps aria-invalid and a danger border until every segment is filled or all are cleared; it never emits a half value |
| empty | Caption modal | ✅ covered | Empty Title/Description inputs show their labels only; both are optional, so an empty submit is valid |
| loading | Caption modal | ✅ covered | While saving, details_submit shows form.saving and both footer buttons are disabled |
| error | Caption modal | ✅ covered | 422: field errors inside the modal (existing rule); any other failure: danger toast form.error_generic, the modal stays open with input kept |
| partial / overflow / long-text | Caption modal | ✅ covered | A long description scrolls inside the 3-row textarea; the file name in the preview strip truncates with title |
| loading | Pivot modal and link step | ✅ covered | While saving, the primary shows form.saving and both footer buttons are disabled (matches the child modal) |
| error | Pivot modal and link step | ✅ covered | 422: form.error_title + form.error_fields banner and field errors inside the dialog, focus on the first invalid field; other failure: form.error_generic danger toast, dialog stays open with input kept; link step 404 on the chosen record returns to step 1 with the list reloaded |
| empty / populated / zero-one-many / overflow | Pivot link step 1 | ✅ covered | Unchanged RelationPickerModal list, empty, search and pagination states (Component Contracts "Unchanged") |
| long-text | Pivot link step 2 and pivot modal | ✅ covered | The chosen record name truncates with title; the body scrolls inside max-h-[calc(100vh-32px)] like the child modal |
| empty / populated / overflow / long-text | Relation toolbar and list | ✅ covered | Unchanged list visuals, 56px rows and pagination; new toolbar buttons keep one primary per toolbar |
| loading | Relation toolbar delete | ✅ covered | The delete confirm button shows the busy state while the request runs; the toolbar is disabled until it settles |
| empty / partial | Child modal create form | ✅ covered | A create form opens with the model defaults; partial input is kept while the modal is open, and a dirty close asks form.unsaved_confirm |
| empty / loading / error / partial / long-text | Unsaved-changes tracking | ➖ dismissed | A behaviour, not a surface: it reuses the existing FormView dirty guard and form.unsaved_confirm ConfirmDialog without changing them; the dirty triggers are listed under FileuploadField and Relation manager |
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | none (shadcn not used) | not applicable |
| third-party registries | none | not applicable |
npm: @internationalized/date@3.12.4 (new direct dependency, not a registry block) |
CalendarDate, CalendarDateTime, ZonedDateTime, Time, parse helpers |
package-legitimacy check [OK] recorded in 12.2-RESEARCH.md (2026-10-02); already present transitively via reka-ui; pinned with --save-exact |
Checker Sign-Off
- Dimension 1 Copywriting: PASS
- Dimension 2 Visuals: PASS
- Dimension 3 Color: PASS
- Dimension 4 Typography: PASS
- Dimension 5 Spacing: PASS
- Dimension 6 Registry Safety: PASS
- Dimension 7 Inventory Provenance: PASS
Approval: approved 2026-10-02 (gsd-ui-checker, after revision 1; FLAG on Dimension 1 accepted: single-word dismiss/step labels kept)