From b906a31c368ad6a59ea8f6ac82121714ffad20da Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 18:33:12 +0200 Subject: [PATCH] docs(19.6.1): UI design contract --- .../19.6.1-UI-SPEC.md | 550 ++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 .planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md diff --git a/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md new file mode 100644 index 00000000..66813765 --- /dev/null +++ b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-UI-SPEC.md @@ -0,0 +1,550 @@ +--- +phase: "19.6.1" +slug: "backend-administrators-screen" +status: approved +reviewed_at: "2026-10-21" +shadcn_initialized: false +preset: none +created: "2026-10-21" +--- + +# Phase 19.6.1 — UI Design Contract + +> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker. + +This phase adds **no new screen design**. The Administrators and Roles screens are YAML-declared cabana controllers (`columns.yaml`, `fields.yaml`, `config_*.yaml`) rendered by the existing generic views in `admin/` (`ListView.vue`, `FormView.vue`, `PreviewView.vue`, `SettingsIndexView.vue`). The contract therefore fixes four things: + +1. **What the YAML declares**: columns, filters, form tabs and field order, preview, record and bulk actions, settings entries. These are layout decisions made with existing components. +2. **Two narrow SPA additions (D-21)**: + - a field `trigger` (`action: disable|hide`, `field`, `condition: checked|unchecked`) evaluated in `FormView`/`PreviewView`; + - per-record locked permission values rendered by `PermissionEditorField.vue`. +3. **All copy** (en + pl) for both screens, the settings entries, the permission catalog, record/bulk actions, the preview status hint and every privilege refusal (D-05, D-07, D-08, D-09, D-17, D-19, D-20). +4. **Where each refusal surfaces** in the existing UI (banner, field line, toast), so the server picks an error shape the SPA already displays. + +The SPA has a complete 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` on Reka UI primitives. **This contract adds no tokens, colours, fonts, radii, shadows or icons outside the existing set.** Every class string below is copied from an existing component (`FormField.vue`, `PermissionEditorField.vue`, `RelationField.vue`, `RecordActions.vue`, `FormErrorBanner.vue`, `RowStateBadges.vue`, `main.css` callout kit). + +Sources: 19.6.1-CONTEXT.md D-01..D-22 (locked); 19.6.1-RESEARCH.md (Patterns 1-6, Pitfalls, Open Questions 1-4 now resolved by D-19..D-21); approved house conventions from the 12.1, 12.2 and 16.3 UI-SPECs; WinterCMS `modules/backend/models/{user,userrole}/*.yaml` and `lang/{en,pl}/lang.php` (`user.*` block) for structure and wording; the codebase scan listed per section. + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none (no shadcn; Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2, 16.3) | +| Preset | not applicable | +| Component library | Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/` | +| Icon library | `@lucide/vue` 1.17.0 (named imports only). Settings tiles use the registry names `users` and `shield-check` through `admin/src/app/icons.ts`; the trigger note uses `Info` 14px; locks use the existing `Lock` 14px | +| Font | DM Sans 400/600/700 (self-hosted `@fontsource/dm-sans`); no DM Mono in this phase | + +--- + +## 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-21. +Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 59 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 053c643) — 2026-10-21. + +Icon presence verified by `node --input-type=module -e "import * as l from '@lucide/vue'; for (const n of ['Lock','Info','CircleAlert','Users','ShieldCheck']) console.log(n, n in l)"` (run in `admin/`) against @lucide/vue@1.17.0 on 2026-10-21: all exported. `users` and `shield-check` are already mapped in `admin/src/app/icons.ts`. + +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 | +|-----------|-------------|-------| +| ListView | `admin/src/views/ListView.vue` | Both lists. Toolbar create/delete, search, filters, bulk actions menu, row-state badges. No change. | +| FormView | `admin/src/views/FormView.vue` | Both forms. Gains trigger evaluation (Contract C6) and threads record permission locks (C7). | +| PreviewView | `admin/src/views/PreviewView.vue` | Administrators preview with the `hint` header partial and `RecordActions`. Gains `hide` trigger evaluation only (C6). | +| FormField | `admin/src/components/form/FormField.vue` | Gains the trigger note line (C6). Label, comment, error and notice markup unchanged. | +| PermissionEditorField | `admin/src/components/form/fields/PermissionEditorField.vue` | Radio mode (per-user overrides) and checkbox mode (role grants). Gains record locks (C7). | +| SwitchField | `admin/src/components/form/fields/SwitchField.vue` | The `is_superuser` toggle card. No change. | +| RelationField | `admin/src/components/form/fields/RelationField.vue` | The `role` belongsTo dropdown. Its existing locked-option handling (`Lock` icon, `form.locked_note`) renders the D-07 role ceiling. No change. | +| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | `password` and `password_confirmation`. No change. | +| RecordActions | `admin/src/components/form/RecordActions.vue` | Preview footer actions, owner's and contributed (D-14, D-15). No change. | +| FormErrorBanner | `admin/src/components/form/FormErrorBanner.vue` | 403 forbidden banner and 422 banner (C8). No change. | +| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | `deleted` and `disabled` states on the Administrators list. No change. | +| PartialHost + `.summer-callout` kit | `admin/src/components/partial/PartialHost.vue`, `main.css` | Preview status hint (`_hint.htm`, html/template, server-rendered). No change. | +| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Record, bulk and delete confirmations. No change. | +| `showToast` | `admin/src/state/useToasts.ts` | Action results and delete refusals. No change. | +| `editablePayload`, `permissionValues` | `admin/src/components/form/formState.ts`, `control.ts` | Payload rules change for triggered fields (C6) and record-locked codes (C7). | + +New SPA code is limited to: trigger evaluation helpers (suggested `admin/src/components/form/trigger.ts`), the note line in `FormField.vue`, the record-lock prop/inject path into `PermissionEditorField.vue`, and the regenerated `admin/src/api/schema.d.ts`. No new `.vue` file is required. + +--- + +## Spacing Scale + +Declared values for new layout in this phase (multiples of 4): + +| Token | Value | Tailwind | Usage | +|-------|-------|----------|-------| +| xs | 4px | `gap-1` | not used by new markup | +| sm | 8px | `gap-2` | Trigger note: icon-to-text gap (copied from the `RelationField` locked note `gap-2`) | +| md | 16px | `px-4` | Permission editor row padding (existing, unchanged) | +| lg | 24px | `gap-x-6` | Form grid column gap (existing `FormGrid`, unchanged) | +| xl | 32px | `px-8` | Sticky footer padding (existing, unchanged) | +| 2xl | 48px | — | not used | +| 3xl | 64px | — | not used | + +Exceptions (inherited from Direction C and already approved as locked in 16.3 on 2026-10-11; re-used verbatim, none introduced): label-to-control gap 6px (`gap-1.5` in `FormField`); form row gap 22px (`gap-y-[22px]` in `FormGrid`); card padding 28px (`p-7`); page stack 20px (`gap-5`); footer button gap 10px (`gap-2.5`); toggle card / callout vertical padding 14px (`py-3.5`); callout padding 14x18 (`.summer-callout`, `px-[18px] py-3.5` banner); permission row 12px vertical padding (`py-3`) and 56px minimum row height. + +The trigger note line sits inside `FormField`'s existing `flex-col gap-1.5` stack, so it adds no new spacing value. + +--- + +## Typography + +New markup uses exactly three styles; everything else is rendered by existing components unchanged. + +| Role | Size | Weight | Line Height | Used by | +|------|------|--------|-------------|---------| +| Body | 14px | 400 | 1.5 | Field values, callout text, toasts, banner text (all existing) | +| Label | 14px | 600 | 1.5 | Field labels, permission row labels, callout titles, button text (all existing) | +| Meta | 13px | 400 | 1.5 | **New:** the trigger note line and the record-lock note line. Existing: field comments, error lines, the locked note | +| Page heading (inherited) | 24px | 700 | 1.2 (`tracking-[-0.02em]`) | Record title in `FormView`/`PreviewView` header, unchanged | + +Weights: new content uses 400 and 600 only. 700 appears only in the inherited page heading (locked system-level exception, as in 16.3). No 12px text is added (the existing 12px row-state badge is reused unchanged). + +--- + +## Color + +All colours are existing CSS variables (light / dark from `main.css`). Hard-coded hex values are forbidden in new markup. + +| Role | Value (light / dark) | Usage | +|------|----------------------|-------| +| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page, form card, list table, permission editor rows | +| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Permission section headers, segmented control track, read-only boxes, the trigger note and lock note text, `Lock`/`Info` icons, the "Deleted" row badge outline | +| Accent (10%) | `primary` #22304d / #fcd34d; `sel` #fdf3cf / #3a3622; `ring` | See reserved list | +| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Form footer Delete (danger outline), delete confirm buttons, forbidden and 422 banners, field error lines, the "deleted" preview callout, danger toasts | +| Semantic OK | `ok-bg` / `ok-text` | Existing "Allow" segment checked state and the "Yes" switch pill on preview; unchanged | + +Accent reserved for (exhaustive for this phase): +1. `primary` fill: the single primary button per screen that already exists: footer **Save** on forms, **Edit administrator** on the preview, **New administrator** / **New role** list toolbar create. No new primary button is added. +2. `primary` track: the `is_superuser` switch when on (existing `SwitchField`), and checked role-grant checkboxes in the permission editor (existing). +3. `sel` tint: the warning variant of the preview status callout (deactivated account; your own account), via `.summer-callout--warning`. +4. `ring` (3px): focus-visible on every focusable element (base style), never removed. + +Accent is never used for: record action buttons (outline), the trigger note, lock icons, row-state badges, settings tiles, or links. + +--- + +## Copywriting Contract + +All copy lives in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` (`backend::lang.*`). New groups: `user:` (with a nested `role:` block, mirroring Winter's `backend::lang.user.*` and `backend::lang.user.role.*` names so ported plugins and docs keep the same keys) and `permissions:`. Additions to existing groups: `form.trigger_*`, `permissioneditor.locked_*`, `settings.category_system`. Placeholders use `:name` / `:count` / `:field`. Plural texts are CLDR maps (`one`/`other` in en; `one`/`few`/`many`/`other` in pl), never pipe strings. Polish is the design language; English values follow the same meaning. + +Validation messages that already exist (`required`, `min.string`, `max.string`, `confirmed`, `unique`) are reused for password and name rules; no new copy for them. + +### Primary CTAs and key states + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Controller record name (`config_form.yaml` `name`) | `user.name` / `user.role.name` | Administrator / Role | Administrator / Rola | +| Administrators list title | `user.list_title` | Administrators | Administratorzy | +| Primary CTA, Administrators (toolbar create; create-form title) | `user.new` | New administrator | Nowy administrator | +| Preview primary (Edit) | `user.edit` | Edit administrator | Edytuj administratora | +| Preview subtitle | `user.preview` | Administrator details | Szczegóły administratora | +| Roles list title | `user.role.list_title` | Roles | Role | +| Primary CTA, Roles (toolbar create; create-form title) | `user.role.new` | New role | Nowa rola | +| Role edit title | `user.role.edit` | Edit role | Edytuj rolę | +| Save, Save and close, Cancel, Delete | existing `form.*` | unchanged | unchanged | +| Empty state, Administrators (`noRecordsMessage`) | `user.list_empty` | No administrators in this view. Clear the search and filters, or create an administrator. | Brak administratorów w tym widoku. Wyczyść wyszukiwanie i filtry lub utwórz administratora. | +| Empty state, Roles (`noRecordsMessage`) | `user.role.list_empty` | No roles in this view. Clear the search, or create a role. | Brak ról w tym widoku. Wyczyść wyszukiwanie lub utwórz rolę. | +| Empty search (both lists) | existing `messages.list.empty_search` / `empty_search_hint` | unchanged | unchanged | +| Error state, list or form load failed | existing `list.load_failed`, `form.load_failed` | unchanged (also the D-03 404 for a hidden superuser) | unchanged | +| Error state, save failed (non-422/403) | existing `form.error_generic` | unchanged | unchanged | +| Saved toast | existing `messages.form.saved` | unchanged | unchanged | + +### Settings entries and permission catalog (D-01, D-02) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Settings category | `settings.category_system` | System | System | +| Administrators tile label | `user.menu_label` | Administrators | Administratorzy | +| Administrators tile description | `user.menu_description` | Manage administrator accounts, their roles and permissions. | Zarządzaj kontami administratorów, ich rolami i uprawnieniami. | +| Roles tile label | `user.role.menu_label` | Roles | Role | +| Roles tile description | `user.role.menu_description` | Define roles and the permissions they grant. | Określ role i uprawnienia, które nadają. | +| Permission tab (catalog section) | `permissions.tab_system` | System | System | +| `backend.manage_users` label | `permissions.manage_users` | Manage administrators | Zarządzanie administratorami | +| `backend.manage_users` comment | `permissions.manage_users_comment` | Create, edit, deactivate and delete administrator accounts. | Tworzenie, edycja, dezaktywacja i usuwanie kont administratorów. | +| `backend.manage_roles` label | `permissions.manage_roles` | Manage roles | Zarządzanie rolami | +| `backend.manage_roles` comment | `permissions.manage_roles_comment` | Create and edit roles and the permissions they grant. | Tworzenie i edycja ról oraz uprawnień, które nadają. | + +### Administrators: list + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Column / filter: login | `user.login` | Login | Login | +| Column: first name | `user.first_name` | First name | Imię | +| Column: last name | `user.last_name` | Last name | Nazwisko | +| Column: email | `user.email` | Email | E-mail | +| Column / filter: role | `user.role_field` | Role | Rola | +| Column / filter: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie | +| Column / filter: superuser (superusers only) | `user.superuser` | Superuser | Superużytkownik | +| Column (invisible): created | `user.created_at` | Created | Utworzono | +| Filter: active | `user.activated` | Active | Aktywny | +| Filter: deleted (scope) | `user.filter_deleted` | Deleted | Usunięci | +| Filter option | `user.filter_deleted_hide` | Hide deleted | Ukryj usuniętych | +| Filter option | `user.filter_deleted_only` | Only deleted | Tylko usunięci | +| Row state `deleted` | `user.state_deleted` | Deleted | Usunięty | +| Row state `disabled` | `user.state_deactivated` | Deactivated | Dezaktywowany | +| Delete selected confirm (danger) | `user.delete_selected_confirm` | one: "Delete :count administrator? They are signed out at once. Deleted accounts can be restored." other: "Delete :count administrators? They are signed out at once. Deleted accounts can be restored." | one: "Usunąć :count administratora? Zostanie od razu wylogowany. Usunięte konta można przywrócić." few/many: "Usunąć :count administratorów? Zostaną od razu wylogowani. Usunięte konta można przywrócić." other: "Usunąć :count administratora? Zostaną od razu wylogowani. Usunięte konta można przywrócić." | +| Deleted toast | existing `messages.list.deleted` | unchanged | unchanged | + +### Administrators: form + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Tab 1 | `user.account` | Account | Konto | +| Tab 2 | `user.permissions` | Permissions | Uprawnienia | +| Password label | `user.password` | Password | Hasło | +| Password comment | `user.password_comment` | At least 4 characters. When editing, leave blank to keep the current password. | Co najmniej 4 znaki. Przy edycji zostaw puste, aby zachować obecne hasło. | +| Password confirmation label | `user.password_confirmation` | Confirm password | Potwierdź hasło | +| Role comment | `user.role_comment` | The role sets this administrator's permissions. Single permissions can be adjusted on the Permissions tab. | Rola określa uprawnienia administratora. Pojedyncze uprawnienia można zmienić w zakładce Uprawnienia. | +| Role empty option | `user.role_none` | No role | Brak roli | +| Superuser comment | `user.superuser_comment` | Grants every permission in the admin, including managing other administrators. Permission overrides below are not used while this is on. | Daje wszystkie uprawnienia w panelu, w tym zarządzanie innymi administratorami. Gdy jest włączone, nadpisania uprawnień poniżej nie są używane. | +| Permission overrides label | `user.permission_overrides` | Permission overrides | Nadpisania uprawnień | +| Permission overrides comment | `user.permission_overrides_comment` | Inherit follows the role. Allow and Deny apply to this administrator only. | Dziedzicz oznacza ustawienie z roli. Zezwól i Odmów dotyczą tylko tego administratora. | +| Delete confirm (form footer, danger) | `user.delete_confirm` | Delete this administrator? They are signed out at once. The account can be restored from the list. | Usunąć tego administratora? Zostanie od razu wylogowany. Konto można przywrócić z listy. | + +### Administrators: preview, record actions and bulk actions (D-10, D-15) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Preview-only field: last sign-in | `user.last_login` | Last sign-in | Ostatnie logowanie | +| Record + bulk action: Activate | `user.activate` | Activate | Aktywuj | +| Activate confirm (record) | `user.activate_confirm` | Activate this administrator? They can sign in again, and their existing API tokens work again. | Aktywować tego administratora? Będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. | +| Activate confirm (bulk) | `user.activate_selected_confirm` | Activate the selected administrators? They can sign in again, and their existing API tokens work again. | Aktywować zaznaczonych administratorów? Będą mogli się zalogować, a ich dotychczasowe tokeny API znów zadziałają. | +| Activate success (record / bulk) | `user.activated` / `user.activated_selected` | Administrator activated / Selected administrators activated | Aktywowano administratora / Aktywowano zaznaczonych administratorów | +| Record + bulk action: Deactivate | `user.deactivate` | Deactivate | Dezaktywuj | +| Deactivate confirm (record) | `user.deactivate_confirm` | Deactivate this administrator? They are signed out at once and cannot sign in until activated again. | Dezaktywować tego administratora? Zostanie od razu wylogowany i nie zaloguje się, dopóki konto nie zostanie ponownie aktywowane. | +| Deactivate confirm (bulk) | `user.deactivate_selected_confirm` | Deactivate the selected administrators? They are signed out at once and cannot sign in until activated again. | Dezaktywować zaznaczonych administratorów? Zostaną od razu wylogowani i nie zalogują się, dopóki ich konta nie zostaną ponownie aktywowane. | +| Deactivate success (record / bulk) | `user.deactivated` / `user.deactivated_selected` | Administrator deactivated / Selected administrators deactivated | Dezaktywowano administratora / Dezaktywowano zaznaczonych administratorów | +| Record + bulk action: Restore | `user.restore` | Restore | Przywróć | +| Restore confirm (record) | `user.restore_confirm` | Restore this administrator? If the account is active, they can sign in again and their existing API tokens work again. | Przywrócić tego administratora? Jeśli konto jest aktywne, będzie mógł się zalogować, a jego dotychczasowe tokeny API znów zadziałają. | +| Restore confirm (bulk) | `user.restore_selected_confirm` | Restore the selected administrators? Active accounts can sign in again, and their existing API tokens work again. | Przywrócić zaznaczonych administratorów? Aktywne konta będą mogły się zalogować, a ich dotychczasowe tokeny API znów zadziałają. | +| Restore success (record / bulk) | `user.restored` / `user.restored_selected` | Administrator restored / Selected administrators restored | Przywrócono administratora / Przywrócono zaznaczonych administratorów | +| Hint: deleted (danger) title / text | `user.trashed_hint_title` / `user.trashed_hint_desc` | This account has been deleted / It cannot be used to sign in. Use Restore to bring it back. | To konto zostało usunięte / Nie można się na nie zalogować. Użyj „Przywróć”, aby je odzyskać. | +| Hint: deactivated (warning) title / text | `user.deactivated_hint_title` / `user.deactivated_hint_desc` | This account is deactivated / It cannot be used to sign in. Use Activate to allow sign-in again. | To konto jest dezaktywowane / Nie można się na nie zalogować. Użyj „Aktywuj”, aby ponownie zezwolić na logowanie. | +| Hint: own account (warning) title / text | `user.self_hint_title` / `user.self_hint_desc` | This is your account / You can change your name, login and email here. Your role, permissions, superuser access and activation can only be changed by another administrator. Change your password in My account. | To Twoje konto / Tutaj zmienisz imię, nazwisko, login i e-mail. Rolę, uprawnienia, dostęp superużytkownika i aktywację może zmienić tylko inny administrator. Hasło zmień w sekcji Moje konto. | + +Contributed record actions (D-14) bring their own label, confirm and success copy from the contributing plugin's phrasebook; this phase adds none. + +### Roles: list and form (D-16, D-17) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Name label / comment | `user.role.name_field` / `user.role.name_comment` | Name / Shown in the role picker on the administrator form. | Nazwa / Wyświetlana na liście ról w formularzu administratora. | +| Code label / comment | `user.role.code_field` / `user.role.code_comment` | Code / A unique identifier used by plugins and the API. The code of a system role cannot be changed. | Kod / Unikalny identyfikator używany przez wtyczki i API. Kodu roli systemowej nie można zmienić. | +| Description label | `user.role.description_field` | Description | Opis | +| Column: system role | `user.role.is_system` | System role | Rola systemowa | +| Permissions tab | `user.permissions` | Permissions | Uprawnienia | +| Granted permissions label / comment | `user.role.permissions_field` / `user.role.permissions_comment` | Granted permissions / Every administrator with this role gets the checked permissions. | Nadane uprawnienia / Każdy administrator z tą rolą otrzymuje zaznaczone uprawnienia. | +| Delete confirm (form footer, danger) | `user.role.delete_confirm` | Delete this role? This cannot be undone. A role that is still assigned to administrators cannot be deleted. | Usunąć tę rolę? Tej operacji nie można cofnąć. Roli przypisanej do administratorów nie można usunąć. | +| Delete selected confirm (danger) | `user.role.delete_selected_confirm` | one: "Delete :count role? This cannot be undone." other: "Delete :count roles? This cannot be undone." | one: "Usunąć :count rolę? Tej operacji nie można cofnąć." few: "Usunąć :count role? Tej operacji nie można cofnąć." many: "Usunąć :count ról? Tej operacji nie można cofnąć." other: "Usunąć :count roli? Tej operacji nie można cofnąć." | + +### Framework additions (D-21) + +| Element | Key | EN | PL | +|---------|-----|----|----| +| Trigger note, `condition: checked` | `form.trigger_disabled_on` | Not editable while “:field” is on. | Nie można edytować, gdy „:field” jest włączone. | +| Trigger note, `condition: unchecked` | `form.trigger_disabled_off` | Not editable while “:field” is off. | Nie można edytować, gdy „:field” jest wyłączone. | +| Record lock note: grant declared in code (Roles) | `permissioneditor.locked_by_code` | Granted to this role by a plugin. It cannot be removed here. | Nadane tej roli przez wtyczkę. Nie można go tu odebrać. | +| Record lock note: your own record (Administrators) | `permissioneditor.locked_self` | You cannot change your own permissions. | Nie możesz zmieniać własnych uprawnień. | + +### Privilege refusals (server copy) + +All of these are phrase keys the server puts in a `ForbiddenError` (`Message` and/or `Details`) or a `ValidationError` detail. Contract C8 maps where each one renders. + +| Decision | Key | EN | PL | +|----------|-----|----|----| +| D-05 | `user.superuser_forbidden` | Only a superuser can grant, remove or change superuser access. | Tylko superużytkownik może nadawać, odbierać lub zmieniać dostęp superużytkownika. | +| D-07 role ceiling (`RelationLock.Message`) | `user.role_ceiling` | You can only assign roles whose permissions you have yourself. | Możesz przypisywać tylko role, których uprawnienia sam posiadasz. | +| D-07 permission ceiling | existing `permissioneditor.locked` | You cannot change this permission. | Nie możesz zmienić tego uprawnienia. | +| D-08 own privileged fields | `user.self_privileged` | You cannot change your own role, permissions, superuser access or activation. Ask another administrator. | Nie możesz zmieniać własnej roli, uprawnień, dostępu superużytkownika ani aktywacji. Poproś innego administratora. | +| D-08 delete yourself | `user.self_delete` | You cannot delete your own account. | Nie możesz usunąć własnego konta. | +| D-08 deactivate yourself | `user.self_deactivate` | You cannot deactivate your own account. | Nie możesz dezaktywować własnego konta. | +| D-09 | `user.last_superuser` | This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first. | To ostatni aktywny superużytkownik, więc nie można go dezaktywować, usunąć ani odebrać mu dostępu superużytkownika. Najpierw nadaj ten dostęp innemu administratorowi. | +| D-19 | `user.outranked` | This administrator has permissions you do not have, so you cannot change, deactivate or delete their account. | Ten administrator ma uprawnienia, których nie masz, więc nie możesz zmieniać, dezaktywować ani usuwać jego konta. | +| D-20 | `user.own_password` | Change your own password in My account, where your current password is checked. | Własne hasło zmień w sekcji Moje konto, która sprawdza obecne hasło. | +| D-17 role has admins | `user.role.delete_has_users` | one: "The role “:name” is assigned to :count administrator. Give them another role first. Deleted accounts count too." other: "The role “:name” is assigned to :count administrators. Give them another role first. Deleted accounts count too." | one: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel mu inną rolę. Liczą się też usunięte konta." few/many: "Rola „:name” jest przypisana do :count administratorów. Najpierw przydziel im inną rolę. Liczą się też usunięte konta." other: "Rola „:name” jest przypisana do :count administratora. Najpierw przydziel inną rolę. Liczą się też usunięte konta." | +| D-17 system role | `user.role.delete_system` | System roles cannot be deleted. | Ról systemowych nie można usunąć. | +| D-16 system role code | `user.role.code_locked` | The code of a system role cannot be changed. | Kodu roli systemowej nie można zmienić. | +| Role code taken | `user.role.code_taken` | This code is already used by another role. | Ten kod jest już używany przez inną rolę. | +| Login taken | `user.login_taken` | This login is already used by another administrator. | Ten login jest już używany przez innego administratora. | +| Login held by a deleted admin | `user.login_taken_deleted` | This login belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten login należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. | +| Email taken | `user.email_taken` | This email is already used by another administrator. | Ten adres e-mail jest już używany przez innego administratora. | +| Email held by a deleted admin | `user.email_taken_deleted` | This email belongs to a deleted administrator. Restore that account instead of creating a new one. | Ten adres e-mail należy do usuniętego administratora. Przywróć to konto zamiast tworzyć nowe. | + +### Destructive actions + +| Action | Confirmation approach | Copy | +|--------|----------------------|------| +| Delete an administrator (form footer) | Existing FormView `confirm.ask({ danger: true })` with `messages.deleteConfirm` | `user.delete_confirm`; confirm button existing `form.delete` | +| Delete selected administrators (list toolbar) | Existing ListView danger confirm with the plural `messages.deleteConfirm` | `user.delete_selected_confirm` | +| Deactivate (record and bulk) | Existing confirm, not `danger` (reversible, `AdminRecordAction.Danger` false) | `user.deactivate_confirm`, `user.deactivate_selected_confirm`; the confirm button label is the action label "Deactivate" | +| Delete a role (form footer) | Existing danger confirm | `user.role.delete_confirm` | +| Delete selected roles | Existing danger confirm | `user.role.delete_selected_confirm` | +| Demote a superuser (switch off and Save) | No dialog: the save itself is the confirmation, as for every form field. The server refuses with D-09 when it would remove the last active superuser | — | + +--- + +## Component Contracts + +### C1. Settings placement (D-02) + +- Two `pact.SettingsItem` link entries in category `backend::lang.settings.category_system`, rendered by the existing `SettingsIndexView` tiles (no SPA change): + - `administrators`: label `user.menu_label`, description `user.menu_description`, icon `users`, `Controller: summercms.cabana.users`, `Permissions: [backend.manage_users]`, order 100; + - `roles`: label `user.role.menu_label`, description `user.role.menu_description`, icon `shield-check`, `Controller: summercms.cabana.roles`, `Permissions: [backend.manage_roles]`, order 110. +- Administrators comes before Roles in the group. A tile appears only when the principal can open its controller (existing filter), so an admin holding only `backend.manage_roles` sees only Roles. +- No top-level navigation item and no section flyout entry. + +### C2. Administrators list + +`config_list.yaml` (shape copied from sm-user-plugin's Users list): +- `title: user.list_title`, `recordUrl` → the record's **preview** (D-15), `recordsPerPage: 20`, `showCheckboxes: true`, `showSetup: true`, `noRecordsMessage: user.list_empty`. +- Toolbar: `buttons: [create, delete]` (create label `user.new`; delete is the soft delete), search prompt `list.search_prompt`. +- `bulkActions: [activate, deactivate, restore]`. +- Messages: `create: user.new`, `empty: user.list_empty`, `deleteConfirm: user.delete_selected_confirm`, `deleted: messages.list.deleted`, `rowStateDeleted: user.state_deleted`, `rowStateDisabled: user.state_deactivated`. No `rowStateNegative`. +- Row states (`ListRowStates`): `deleted` when `deleted_at` is set; `disabled` when not activated. A deleted and deactivated row shows both badges, in `RowStateBadges`' fixed order. + +Columns, in this order: + +| Column | Type | Visible | Searchable / sortable | Notes | +|--------|------|---------|----------------------|-------| +| `login` | text | yes | searchable, sortable | | +| `first_name` | text | yes | searchable, sortable | | +| `last_name` | text | yes | searchable, sortable | | +| `email` | text | yes | searchable, sortable | | +| `role` | relation `role`, select `name` | yes | sortable | Empty cell shows the existing `list.empty_value` dash | +| `last_login` | datetime | yes | sortable | Never signed in shows the dash | +| `is_superuser` | switch | yes, **superusers only** | — | Removed from the schema for non-superusers by the same per-request hook that removes the field and filter (D-03) | +| `created_at` | datetime | invisible | sortable | Available through list setup | + +Filters (supported types only: `switch`, `daterange`, `scope`): + +| Scope | Type | Label | Notes | +|-------|------|-------|-------| +| `role` | scope (`modelClass` role, `nameFrom: name`) | `user.role_field` | | +| `is_superuser` | switch, column `is_superuser` | `user.superuser` | Superusers only (D-03) | +| `activated` | switch, column `is_activated` | `user.activated` | | +| `last_login` | daterange, column `last_login` | `user.last_login` | | +| `trashed` | scope with two options | `user.filter_deleted` | Options `user.filter_deleted_hide` and `user.filter_deleted_only`. With no option chosen the list shows every administrator, deleted ones marked with the "Deleted" badge (the list query is unscoped, as in sm-user-plugin) | + +### C3. Administrators form + +`config_form.yaml`: `name: user.name` (en "Administrator", pl "Administrator"), `preview: { headerPartial: hint }`, `recordActions: [activate, deactivate, restore]`, redirects copied from sm-user-plugin (create → the new record's preview; update `redirect` → stays on update; `redirectClose` → preview), messages `create: user.new`, `update: messages.form.update`, `saved: messages.form.saved`, `deleteConfirm: user.delete_confirm`, `deleted: messages.form.deleted`, `edit: user.edit`, `preview: user.preview`. + +Record title (header h1, breadcrumb, `:name` in messages): "First Last" when either name is set, otherwise the login. + +Fields, by tab (two-column `FormGrid`; `left`/`right` pair on one row): + +**Tab `user.account` (the default tab)** + +| Field | Type | Span | Context | Required | Notes | +|-------|------|------|---------|----------|-------| +| `login` | text | left | create, update, preview | yes | | +| `email` | text | right | create, update, preview | yes | | +| `first_name` | text | left | all | no | | +| `last_name` | text | right | all | no | | +| `password` | password (virtual) | left | create, update | create only | Comment `user.password_comment`. Blank on update means unchanged (existing `editablePayload` rule) | +| `password_confirmation` | password (virtual) | right | create, update | create only | | +| `role` | relation belongsTo, `nameFrom: name`, `emptyOption: user.role_none` | full | all | no | Comment `user.role_comment`. Roles above the editor's ceiling render locked through `RelationLockProvider` (D-07), using `RelationField`'s existing lock icon and `form.locked_note` | +| `last_login` | datepicker (datetime) | left | **preview only** | — | Read-only display on the preview screen; never in a save body | + +**Tab `user.permissions`** + +| Field | Type | Span | Context | Notes | +|-------|------|------|---------|-------| +| `is_superuser` | switch (virtual, projected) | full | create, update, preview | Label `user.superuser`, comment `user.superuser_comment`. Present only for superusers (D-03). Renders as the existing toggle card | +| `permissions` | permissioneditor, `mode: radio` | full | create, update, preview | Label `user.permission_overrides`, comment `user.permission_overrides_comment`. `trigger: { action: disable, field: is_superuser, condition: checked }` (C6, D-06). Options locked above the editor's grants (D-07, existing `Locked`); every option record-locked on the editor's own record (C7, D-08) | + +- The tab order is fixed: Account, then Permissions. The Permissions tab is always present (a non-superuser still sees the overrides editor without the switch). +- The `FormView` footer is unchanged: Delete (danger outline, update only), Cancel (ghost), Save and close (outline), Save (primary). +- No client-side validation is added; 422 errors render under fields and in the existing banner, and the first invalid field's tab is activated (existing). + +### C4. Administrators preview (D-15) + +- Rendered by `PreviewView` unchanged. Header: back button, record title, subtitle `user.preview`, and the two tabs. +- **Status hint** (`headerPartial: hint`, server-rendered with the `.summer-callout` kit exactly as sm-user-plugin's `_hint.htm`): one callout or nothing, by precedence: + 1. deleted → `summer-callout--danger`, `user.trashed_hint_title` / `user.trashed_hint_desc`; + 2. deactivated → `summer-callout--warning`, `user.deactivated_hint_title` / `user.deactivated_hint_desc`; + 3. the record is the signed-in administrator's own → `summer-callout--warning`, `user.self_hint_title` / `user.self_hint_desc`; + 4. otherwise no callout. + The callout has `role="status"`; the hint is refetched after every record action (existing `hintKey`). +- Fields shown: Account tab `login`, `email`, `first_name`, `last_name`, `role` (relation text box), `last_login` (read-only datepicker); Permissions tab `is_superuser` (Yes/No pill, superusers only) and `permissions` (read-only editor, no lock icons on a read-only screen, existing rule). Password fields never show (existing preview rule). +- **Footer actions, left to right inside the right-aligned group**: the owner's record actions in declared order (`activate`, `deactivate`, `restore`, each only while it applies), then contributed actions (D-14) in boot merge order, then the primary **Edit administrator** button. All record actions are outline buttons (existing `RecordActions`). Applies rules: + - `activate`: not deleted and not activated; + - `deactivate`: not deleted, activated, and **not the editor's own record** (the action is not offered to yourself; the server still refuses it with `user.self_deactivate`); + - `restore`: deleted. + So a record shows at most one of Activate / Deactivate / Restore. A record action that targets the last active superuser is still offered and refused with `user.last_superuser` (the check needs the lock and is not a pure read). +- Every action asks for confirmation (existing). Success: toast with the action's success phrase, record and hint reload. 403: danger toast with the server message (C8). 409: existing stale toast and reload. +- A contributed action with `Danger: true` uses the danger confirm button; its footer button stays outline (existing rule). + +### C5. Roles list and form (D-16, D-17) + +List (`config_list.yaml`): `title: user.role.list_title`, `recordUrl` → the record's update form (Roles has **no preview block**), toolbar `[create, delete]`, `showCheckboxes: true`, no bulk actions, `noRecordsMessage: user.role.list_empty`, messages `create: user.role.new`, `deleteConfirm: user.role.delete_selected_confirm`, `deleted: messages.list.deleted`. + +| Column | Type | Visible | Searchable / sortable | +|--------|------|---------|----------------------| +| `name` | text | yes | searchable, sortable | +| `code` | text | yes | searchable, sortable | +| `description` | text | yes | searchable | +| `is_system` | switch | yes | sortable | +| `created_at` | datetime | invisible | sortable | +| `updated_at` | datetime | invisible | sortable | + +No users-count column in this phase (cabana columns have no count/`valueFrom`; D-17's refusal names the count instead). + +Form (`config_form.yaml`): `name: user.role.name` (en "Role", pl "Rola"), messages `create: user.role.new`, `update: user.role.edit`, `saved: messages.form.saved`, `deleteConfirm: user.role.delete_confirm`, `deleted: messages.form.deleted`. Create redirects to the new role's update form; Save and close returns to the list. + +| Field | Type | Span | Tab | Notes | +|-------|------|------|-----|-------| +| `name` | text | left | default (`form.tab_default`) | Required. Comment `user.role.name_comment` | +| `code` | text, `preset: { type: slug, field: name }` | right | default | Required. Comment `user.role.code_comment`. Follows the name on create until edited (existing preset rule); never follows on update. A change on a system role is refused with `user.role.code_locked` on the `code` field | +| `description` | textarea, `size: tiny` | full | default | | +| `permissions` | permissioneditor, `mode: checkbox` | full | `user.permissions` | Label `user.role.permissions_field`, comment `user.role.permissions_comment`. Code-declared grants render record-locked (C7, note `permissioneditor.locked_by_code`); permissions the editor lacks render request-locked (existing, note `permissioneditor.locked`) | + +### C6. Field `trigger` (D-21, used by D-06) + +**YAML and schema.** `trigger` becomes a valid key in `formFieldKeys`: + +```yaml +trigger: + action: disable # disable | hide + field: is_superuser # another field of the same form (same fields.yaml) + condition: checked # checked | unchecked +``` + +- Boot errors (fail fast, like every unknown key): a missing sub-key; an action other than `disable`/`hide`; a condition other than `checked`/`unchecked` (Winter's `value[...]` is not supported in this phase); a `field` that names no field of the same form, names itself, or names a field whose type is not `switch` or `checkbox`; `trigger` on a field nested in a repeater form. +- The form schema payload carries it as `trigger: { action, field, condition }` on `cabana.FormField` (swag annotation → `admin/openapi/admin.json` → `schema.d.ts`, `scripts/check-admin-openapi.sh`). + +**Evaluation (SPA).** One pure helper (suggested `admin/src/components/form/trigger.ts`) takes the field, the **whole form's** values (FormView `values`, not the active tab's scope, because the controlling field may sit on another tab) and the set of field names present in the filtered schema, and returns `'active' | 'inactive'`: +- `checked`: active when the controlling value is on by `toggleOn` (`true`, `1`, `"1"`, `"true"`); +- `unchecked`: active when it is off; +- a controlling field absent from the schema for this request (for example `is_superuser` removed for a non-superuser, D-03) or excluded by `context` counts as **off**. +It re-evaluates on every value change, immediately (no animation, no debounce). + +**`disable` while active (FormView):** +- The field renders through its own control **as read-only** (the control receives the field with `readOnly: true`, exactly as `PreviewField` does), keeping its current values visible. For `permissioneditor` this disables every segment / checkbox and, by the existing rule, hides the per-row lock icons and lock notes. +- A note line renders in `FormField` **between the label and the control** (so it is visible above a long permission editor): `

` with `Info` 14px (`shrink-0`, `aria-hidden`) and `form.trigger_disabled_on` (condition `checked`) or `form.trigger_disabled_off` (condition `unchecked`), `:field` = the controlling field's label. Its id joins the control's `aria-describedby`. +- **Payload:** the field is left out of the save body (`editablePayload` skips it). The server then keeps the stored value: an absent `permissioneditor` key is not lifted (`liftPermissionValues` skips absent keys) and an absent scalar is not filled. Values typed before the field was disabled stay in the form and return when it is enabled again. +- **Announcement:** when a user edit flips a trigger, the form's existing polite live region (`announce`) says ": " on disable, and nothing on enable. + +**`hide` while active (FormView):** +- The field is not rendered, is left out of the save body, and is left out of the tab computation: a tab whose every field is hidden disappears, and if it was the active tab the first remaining tab becomes active. +- Its name is also left out of `FormErrorBanner`'s `fieldNames`, so a server message for a hidden field is shown in the banner as an orphan line rather than lost. + +**PreviewView:** `hide` is evaluated the same way against the record's values; `disable` has no effect (every preview field is already read-only) and no note is shown. + +**Dirty state:** because the payload changes when a trigger flips, toggling the controlling field marks the form dirty exactly once per real change (existing snapshot comparison); no extra rule. + +**D-06 outcome on the Administrators form:** a superuser opens another superuser's record → Permissions tab shows the Superuser card on, then "Permission overrides" with the note "Not editable while “Superuser” is on." and a read-only editor. Turning the switch off enables the editor at once. A non-superuser never sees the switch and always gets an editable editor (subject to D-07 locks). + +### C7. Per-record locked permission values (D-21, used by D-16 and D-08) + +**Data.** The record response carries, per `permissioneditor` field, the codes the server fixes for **this record**, with the value to show and the note to show. Recommended shape (planner may rename; the rendering rules below are fixed): `meta.permissionLocks: { [fieldName]: Array<{ code: string, value: number, note: string }> }`, where `note` is already translated in the request locale. It is absent when no record lock applies, and it is never part of the form schema (the schema is fetched without a record id). `FormView` (and `PreviewView`) pass it to the field through the same path as `meta.labels` (prop through `FormGrid`/`FormField`/`FieldRenderer`, or a provided context; planner's call). + +**Server use in this phase:** +- Roles (D-16): every code-declared grant of the role (`pact.Permission.Roles` for its code), `value: 1`, note `permissioneditor.locked_by_code`. +- Administrators (D-08): on the editor's own record, every offered code, `value` = the stored override (1, -1, or 0 for inherit), note `permissioneditor.locked_self`. + +**Rendering in `PermissionEditorField`** (editable screens): +- A record-locked row shows `value` (checkbox checked for 1; the radio segment for 1 / 0 / -1), its control is disabled with `aria-disabled="true"`, the existing `Lock` 14px icon follows the label, and the note line (`text-[13px] text-muted`, existing lock-note markup and id, linked by `aria-describedby`) shows the record lock's `note` instead of `permissioneditor.locked` or the option comment. The row gets `data-locked` and `data-locked-by="record"`. +- When a row is both request-locked (the editor lacks the code) and record-locked, the record lock's value and note win. +- On a read-only screen (preview, or a trigger-disabled editor) record locks render like every lock there: value shown, no icon, no note (existing `showsLock` rule). + +**Payload.** Record-locked codes are **never sent**, exactly like non-offered codes (`permissionValues` skips them). The server keeps whatever it stores for them and skips them in the locked-value comparison, so a role whose code grant is not in its stored JSON saves without a 403. The displayed value comes from the lock, not from the stored JSON. + +### C8. Where each refusal surfaces + +The SPA shows a 403 save refusal as the `FormErrorBanner` forbidden banner (`error.message`, else `form.forbidden`) plus field lines for `error.details`; a 422 as field lines plus the error-count banner; and a delete or action refusal as a danger toast with `error.message`. A `ValidationError` has no message of its own (the envelope says "Validation failed"), so **every refusal that can come back from a delete or an action must be a `ForbiddenError` with `Message` set**, or its text is lost in the toast. + +| Rule | Path | Status / error | Renders as | Copy | +|------|------|----------------|-----------|------| +| D-03 hidden superuser opened by id | GET record | 404 | Existing `form.load_failed` alert on the form/preview | existing | +| D-05 `is_superuser` sent by a non-superuser | create / update | 403, `Message` + `Details.is_superuser` | Forbidden banner | `user.superuser_forbidden` | +| D-07 locked permission changed | create / update | 403 (existing `applyPermissionValues`) | Forbidden banner + line under the editor | `permissioneditor.locked` | +| D-07 locked role assigned | create / update | 403 (existing `checkRelationLocks`) | Forbidden banner + line under Role | `user.role_ceiling` (`RelationLock.Message`) | +| D-08 own role / superuser / permissions changed | update | 403, `Message` + `Details` on the changed field | Forbidden banner + field line | `user.self_privileged` | +| D-08 delete yourself | form delete, list bulk delete | 403, `Message` | Danger toast | `user.self_delete` | +| D-08 deactivate yourself | bulk action (record action is not offered) | 403, `Message` | Danger toast | `user.self_deactivate` | +| D-09 last active superuser | update (demote) | 403, `Message` + `Details.is_superuser` | Forbidden banner + line under the switch | `user.last_superuser` | +| D-09 last active superuser | delete, bulk delete, deactivate (record / bulk) | 403, `Message` | Danger toast | `user.last_superuser` | +| D-19 target outranks the editor | update | 403, `Message` | Forbidden banner | `user.outranked` | +| D-19 target outranks the editor | delete, bulk delete, record / bulk actions | 403, `Message` | Danger toast | `user.outranked` | +| D-20 own password on this screen | update | 403, `Message` + `Details.password` | Forbidden banner + line under Password | `user.own_password` | +| Login / email taken (active or deleted) | create / update | 422, `Details.login` / `Details.email` | Field line + count banner | `user.login_taken[_deleted]`, `user.email_taken[_deleted]` | +| Password rules (required on create, min 4, max 72 bytes, confirmed) | create / update | 422, `Details.password` | Field line | existing validation messages | +| D-16 system role code changed | update | 422, `Details.code` | Field line | `user.role.code_locked` | +| Role code taken | create / update | 422, `Details.code` | Field line | `user.role.code_taken` | +| D-17 role still has admins | form delete, bulk delete | 403, `Message` (see note) | Danger toast | `user.role.delete_has_users` with `:name`, `:count` | +| D-17 system role | form delete, bulk delete | 403, `Message` | Danger toast | `user.role.delete_system` | + +Note on D-17: CONTEXT says "validation error". The visible requirement is that the toast names the count. With the current SPA that needs `ForbiddenError{Message}`; a `ValidationError` would show "Validation failed". The planner either uses the 403 shape or changes the SPA delete toast to read `error.details`. The 403 shape is recommended because it needs no SPA change. One refused row in a bulk delete rolls back the whole selection (existing), and the toast names the first refused role. + +--- + +## UI Considerations + +Applicable state considerations resolved: 9 elements (probe-checked 2026-10-21; 7 gap rows added after the probe) (Administrators list, Administrators form, Administrators preview with record actions, Roles list, Roles form, settings tiles, trigger mechanism, record permission locks, refusal surfaces). Copy for empty and error rows lives in the Copywriting Contract. + +| Category | Element(s) | Status | Resolution / Reason | +|----------|------------|--------|---------------------| +| empty | Administrators list | ✅ covered | With no rows (search or filters) the list shows `user.list_empty`; an empty search shows the existing `messages.list.empty_search` + hint. Without search or filters the list always holds at least the viewer's own account, so the empty copy only shows for filtered views | +| empty | Roles list | ✅ covered | `user.role.list_empty`; the seeded system roles mean an unfiltered list is never empty | +| loading | Both lists, both forms, preview | ✅ covered | Existing skeletons and disabled footers in `ListView`/`FormView`/`PreviewView`; no new loading UI | +| error | Both lists, both forms, preview | ✅ covered | Existing `list.load_failed` / `form.load_failed` alerts; a hidden superuser opened by id (D-03) lands in the same alert | +| populated | Administrators list | ✅ covered | Columns login, first name, last name, email, role, last sign-in, superuser (superusers only); deleted and deactivated rows carry text badges | +| partial | Administrators list (mixed states) | ✅ covered | Active, deactivated, deleted and deleted+deactivated rows coexist; each state is a text badge in fixed order (WCAG 1.4.1) | +| zero-one-many | Bulk actions | ✅ covered | Bulk menu is enabled only with a selection (existing); one refused row rolls back the whole selection and the toast shows the refusal | +| overflow | Both lists | ✅ covered | Existing pagination (20 per page) and horizontal table scroll; long emails wrap per existing `CellValue` rules | +| long-text | Role name / record title in confirms and toasts | ✅ covered | `ConfirmDialog` description wraps with `[overflow-wrap:anywhere]` (added in 16.3); the record title truncates in the header h1 (existing `truncate`) | +| populated | Administrators form, Account tab | ✅ covered | Login/email, first/last name, password/confirmation pairs, full-width role picker with locked roles shown locked | +| partial | Administrators form, superuser target | ✅ covered | Permissions tab shows the Superuser card on and the overrides editor read-only with the trigger note (C6) | +| partial | Administrators form, own record | ✅ covered | Every override row is record-locked with `permissioneditor.locked_self` (C7); a change of role or the Superuser switch is refused with `user.self_privileged` in the forbidden banner; a password change is refused with `user.own_password` under Password | +| partial | Administrators form, non-superuser editor | ✅ covered | No Superuser switch, column or filter; permissions the editor lacks are locked rows; roles above the editor's ceiling are locked options | +| empty | Permission editor with no catalog | ✅ covered | Existing `permissioneditor.empty` box; cannot happen in practice once the two `backend.*` codes are registered | +| overflow | Permission editor (many plugin permissions) | ✅ covered | Sections per catalog tab, page scroll; the trigger note sits above the editor so it stays visible without scrolling to the comment | +| error | Save refused (403) | ✅ covered | Forbidden banner with the server message, field lines for named fields, values and dirty state kept (existing); mapping in C8 | +| error | Save invalid (422) | ✅ covered | Field lines, count banner, first invalid field's tab activated and focused (existing) | +| populated | Administrators preview | ✅ covered | Status callout by precedence deleted > deactivated > own account; footer with at most one of Activate / Deactivate / Restore, then contributed actions, then Edit | +| error | Record action refused (403) / stale (409) / gone (404) | ✅ covered | Danger toast with server message; 409 existing stale toast and reload; 404 existing load-failed state | +| zero-one-many | Contributed record actions | ✅ covered | Zero: only owner actions; one or many: outline buttons after the owner's, in boot merge order, wrapping with the footer's `flex-wrap` | +| long-text | Contributed action labels | 🧪 backstop | An SPA test renders the preview footer with three owner-style actions plus two contributed actions with 40-character labels at 1024px and asserts the footer wraps without overlapping the Edit button | +| populated | Roles form, system role | ✅ covered | Code-declared grants checked and locked with `permissioneditor.locked_by_code`; extra grants editable within the D-07 ceiling; a code change refused under Code | +| populated | Settings tiles | ✅ covered | Two tiles under "System", each shown only to holders of its permission (or superusers) | +| populated | Trigger `disable` | 🧪 backstop | An SPA unit test: a radio permission editor with `trigger: {action: disable, field: is_superuser, condition: checked}` is read-only with the note when the switch is on, editable when off, and its key is absent from `editablePayload` while disabled | +| partial | Trigger, controlling field absent | 🧪 backstop | An SPA unit test: with `is_superuser` missing from the schema the editor is editable and sent | +| partial | Trigger `hide` on a whole tab | 🧪 backstop | An SPA unit test: hiding every field of the active tab removes the tab, activates the first tab, and a server error on the hidden field appears in the banner | +| populated | Record locks | 🧪 backstop | An SPA unit test: a checkbox editor with `permissionLocks` shows the locked code checked and disabled with the record note, and `permissionValues` omits it from the payload | +| empty | Administrators create form | ✅ covered | Blank form; password and confirmation are required on create (D-11); missing or mismatched fields come back as 422 field lines with the count banner (existing) | +| long-text | Administrators and Roles form inputs | ✅ covered | Existing full-width text inputs; long login, email, name or description values scroll inside the input; the header h1 record title truncates (existing `truncate`) | +| empty | Roles create form | ✅ covered | No record locks on a new role; the checkbox editor starts all unchecked and editable within the D-07 ceiling (codes the editor lacks are locked rows) | +| error | Roles form refusals | ✅ covered | A system role's code change is refused under Code (D-16); deleting a role that still has admins returns 403 with `user.role.delete_has_users` in a danger toast (D-17, C8) | +| zero-one-many | Roles bulk delete | ✅ covered | One selected role that still has admins (or is a system role) refuses the whole selection; the transaction rolls back and the danger toast names that role | +| error | Invalid `trigger` YAML | ✅ covered | Unknown `action`, `condition` or a `field` not in the form is a boot error naming controller, field and key; it never reaches the SPA | +| zero-one-many | Record permission locks | ✅ covered | No locks: plain editor. Some locks: those rows are locked with the record's note, others editable. All locked (editing your own record, D-08): the whole editor is read-only | +| loading / empty | Trigger note, lock note | ➖ dismissed | Static text derived synchronously from schema and record; no async state | + +--- + +## Registry Safety + +| Registry | Blocks Used | Safety Gate | +|----------|-------------|-------------| +| shadcn official | none (shadcn not used) | not applicable | +| third-party registries | none | not applicable | +| npm dependencies | none added (Reka UI, `@lucide/vue`, Tailwind already installed at the versions above) | not applicable | + +--- + +## 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:** pending