From d1650e8213997d88fa31a2f4acc04e3df5f7409f Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 15:52:22 +0200 Subject: [PATCH] docs(12.2): UI design contract --- .../12.2-UI-SPEC.md | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md diff --git a/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md b/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md new file mode 100644 index 0000000..ab43563 --- /dev/null +++ b/.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md @@ -0,0 +1,443 @@ +--- +phase: "12.2" +slug: "admin-form-fields-date-file-upload-relation-editing-with-def" +status: approved +reviewed_at: "2026-10-02" +shadcn_initialized: false +preset: none +created: "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 `` 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 `