Files
summercms/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md
2026-10-02 15:52:22 +02:00

50 KiB
Raw Blame History

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 than rounded-pager so 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):

  1. primary fill: the single primary button of each surface (the modal *_submit labels "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.
  2. ring (3px): focus-visible on every new focusable element and :focus-within on the date field container. Never removed.
  3. sel tint: the focused date segment, the dropzone while a file is dragged over it, the reorder drop slot, and the "Unsaved" chip.
  4. primary border: 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"> with min-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 40px bg-surface circle with the Upload icon (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 has accept built from fileTypes/mimeTypes (or image/* style defaults for mode: image) and multiple for attachMany.
  • While a file is dragged over it: border-solid border-primary bg-sel. Drops on the rest of the page are ignored (preventDefault on 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> uses object-cover for thumbOptions.mode crop/exact and object-contain for fit/auto, with alt = 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 (title attribute holds the full name), size 13px muted tabular-nums (1.2 MB, decimal units, one decimal).
  • Overlay actions: top-right at top-2 right-2, flex gap-1. Each is a 32×32 rounded-pager bg-surface border border-border text-muted hover:text-text icon button: Pencil (only with useCaption, aria fileupload.edit_details) and X (aria form.remove_item). The X turns text-danger hover:bg-danger-soft on hover.
  • The drag handle is a 32×32 GripVertical button at top-2 left-2 with the same style (aria fileupload.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:

  • GripVertical handle 32×32 (attachMany only);
  • a 32×32 rounded-pager bg-subtle square holding FileText 16px muted, or a 32×32 object-cover thumbnail when the file is an image;
  • a name column: caption title (14px/600) above the file name (13px muted) when useCaption has a title, else the file name alone at 14px/400, truncated;
  • size, 13px muted tabular-nums;
  • actions: Download (aria fileupload.download), Pencil (with useCaption), and X, 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 a border-2 border-dashed border-primary bg-sel placeholder.
  • 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-live region announces fileupload.moved.
  • On drop or key move, send the full order once. On failure, restore the previous order and show the reorder_failed danger 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-inner thumbnail or icon square, plus the file name 13px muted.
  • Fields: Title (44px text input, label title_label) and Description (textarea, 3 rows, label description_label), both styled with controlClass.
  • Footer: two equal-width buttons, Cancel (outline) and fileupload.details_submit "Save details" (primary). Saving applies immediately (A7), closes the modal, updates the item and shows the details_saved toast. 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).

  1. update declared: open the child modal in update mode.
  2. Else belongsToMany with pivot.form: open the pivot modal.
  3. Else view.form declared: open the child modal read-only (title messages.relation.preview_title, footer shows only Close).
  4. 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 primary fill 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: DialogContent with fixed 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-pop and the existing 200ms fade/scale.
  • Header (p-6 pb-4, flex items-start gap-3): title, messages.relation.create_title, update_title or preview_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): FormErrorBanner when there are errors, FormTabs when the child fields declare tabs, then FormGrid with idPrefix="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" labelled form.delete, only in update mode when delete is declared. On the right (ml-auto flex gap-2.5): Button variant="ghost" form.cancel, then Button variant="primary" with messages.relation.create_submit (create) or messages.relation.update_submit (update). While saving, the primary button shows form.saving and 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 44px rounded-control bg-skel bar.
  • Load failure: the body shows the alert block (rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger, role="alert") with relation.child_load_failed; the footer keeps only Cancel.
  • 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_gone danger toast, reload the list.
  • Success: close the modal, reload the list, toast created or updated.
  • Focus: on open, the first focusable field. On close, focus returns to the create button 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 FormGrid built from pivot.form;
  • footer with relation.back (outline, returns to the list keeping search, page and selection) and messages.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: true render 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, Info 14px, text relation.pending_note.
  • All list, create, link, unlink and delete calls use record id 0 with the form's X-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

  • FormView generates one session key when the view mounts (one per route instance; the view is keyed by path). It is 32 random bytes from crypto.getRandomValues, base64url-encoded.
  • The key is sent as X-Session-Key on 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/maxFiles errors 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)