docs(12.2): UI design contract
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: PASS
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: PASS
|
||||
- [x] Dimension 5 Spacing: PASS
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
- [x] 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)
|
||||
Reference in New Issue
Block a user