diff --git a/.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md b/.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
new file mode 100644
index 0000000..d7765b6
--- /dev/null
+++ b/.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
@@ -0,0 +1,568 @@
+---
+phase: "12.1"
+slug: "user-plugin-admin-screens"
+status: draft
+shadcn_initialized: false
+preset: none
+created: "2026-10-04"
+---
+
+# Phase 12.1 — UI Design Contract
+
+> Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
+
+**Scope of this contract.** Phase 12.1 adds seven framework-generic surfaces to the existing admin SPA (`admin/`), and three YAML-driven plugin screens that use them. It does not restyle anything that exists.
+
+1. **Bulk actions menu** on a list (D-09).
+2. **Record actions** on one record (D-10).
+3. **Preview screen**: a read-only record view with its own toolbar and a status hint slot (D-11).
+4. **Row state** on list rows (D-12).
+5. **`permissioneditor` field** (D-16).
+6. **Locked options** in a `relation` field and the **forbidden (403) presentation** (D-07, RESEARCH gap G4).
+7. **`password` field** (D-19, RESEARCH gaps G1/G2), plus the small `preset` behaviour (G7).
+
+Surfaces 6 and 7 depend on framework seams that RESEARCH.md lists as not yet confirmed by the user (G1, G2, G4, G7). The visual contract below is binding **if** the seam lands; if the user cuts a seam at the plan-count checkpoint, its section is dropped, not redesigned.
+
+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 new tokens, colours, fonts or radii.** It adds one group of plain CSS classes to the partial style kit (`.summer-callout*`), which read only existing `--c-*` variables.
+
+Framework copy is neutral and never names an application. Plugin copy (the `golem15.user` screens) is listed separately under "Plugin screens".
+
+Sources: 12.1-CONTEXT.md D-01..D-25 (locked), 12.1-RESEARCH.md (PHP screen inventory, gaps G1–G12, contract names), the 10.1 and 12.2 UI-SPECs (precedent), the codebase (`main.css`, `ListView.vue`, `ListToolbar.vue`, `DataTable.vue`, `CellValue.vue`, `FormView.vue`, `FormTabs.vue`, `FormField.vue`, `FormErrorBanner.vue`, `RelationField.vue`, `UserMenu.vue`, `Button.vue`, `ConfirmDialog.vue`, `confirm.ts`, `registry.ts`, `winterUrl.ts`, `router.ts`, `partialNodes.ts`, `modules/phrasebook/backend/lang/{en,pl}/lang.yaml`, `modules/cabana/messages.go`), and the PHP reference at `plugins/golem15/user` (toolbars, hints, permission editor partial, `lang/{en,pl}/lang.php`).
+
+---
+
+## 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) |
+| Preset | not applicable |
+| Component library | Reka UI 2.9.10 (headless primitives) plus the local generic components in `admin/src/components/` |
+| Icon library | `@lucide/vue` 1.17.0 (named imports only; 16px in buttons and menu items, 14px in chips, badges and error lines) |
+| Font | DM Sans 400/500/600/700 (self-hosted `@fontsource/dm-sans`); 14px / 1.5 base from `html` |
+| npm changes | **None.** The exact-pin package gate is unchanged (RESEARCH: "This research recommends no new npm package") |
+
+---
+
+## 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-04.
+Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 38 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 0b25a1d) — 2026-10-04.
+
+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 |
+|-----------|-------------|-------|
+| DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuItem | `reka-ui` | Bulk actions menu. Same parts and classes as `shell/UserMenu.vue`. Verified exported. |
+| RadioGroupRoot, RadioGroupItem | `reka-ui` | `permissioneditor` radio mode (one group per permission row). Verified exported. |
+| CheckboxRoot, CheckboxIndicator | `reka-ui` | `permissioneditor` checkbox mode. Verified exported. |
+| AlertDialog* | `reka-ui` via `components/ui/ConfirmDialog.vue` | Every confirmation. Do not build a second confirm. |
+| ConfirmDialog + `useConfirm` | `admin/src/components/ui/ConfirmDialog.vue`, `confirm.ts` | Use `confirm.ask(request, run)` so the dialog stays open and `busy` while the action's POST runs. |
+| Button | `admin/src/components/ui/Button.vue` | Variants `primary` / `outline` / `ghost` / `danger`, sizes `md` (42px) / `sm` (38px). Every new text button uses it. |
+| ListToolbar, DataTable, CellValue, FilterBar, Pagination | `admin/src/components/list/` | ListToolbar and DataTable are extended, not forked. FilterBar and Pagination are unchanged. |
+| FormGrid, FormField, FormTabs, FormErrorBanner | `admin/src/components/form/` | FormTabs reused unchanged on the preview screen. FormErrorBanner gains the forbidden variant. |
+| RelationField | `admin/src/components/form/fields/RelationField.vue` | Extended with locked options. |
+| TextField, `controlClass`, `controlAttributes` | `admin/src/components/form/fields/TextField.vue`, `form/control.ts` | Geometry source of the password input. |
+| PartialHost | `admin/src/components/partial/PartialHost.vue` | Renders the preview status hint (header variant). |
+| `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. |
+| `mapWinterUrl`, `CONTROLLER_ROUTES` | `admin/src/app/winterUrl.ts`, `app/router.ts` | Learn `preview/:id` and the `preview` route name. |
+| Icons used by this phase | `@lucide/vue` | `ChevronDown`, `Pencil`, `ArrowLeft`, `Lock`, `Eye`, `EyeOff`, `Check`, `CircleAlert`, `Trash2`, `LoaderCircle` (all verified present in 1.17.0). |
+
+**New components (framework):**
+
+| Component | Path | Purpose |
+|-----------|------|---------|
+| BulkActionsMenu | `admin/src/components/list/BulkActionsMenu.vue` | The menu of declared bulk actions in the list toolbar. |
+| RowStateBadges | `admin/src/components/list/RowStateBadges.vue` | Text badges for a row's states, rendered by DataTable in the first cell. |
+| PreviewView (or a `preview` mode of `FormView.vue`, planner's choice) | `admin/src/views/` | The read-only record screen. |
+| PreviewField | `admin/src/components/form/PreviewField.vue` | Read-only rendering of one field value by type. |
+| PermissionEditorField | `admin/src/components/form/fields/PermissionEditorField.vue` | `type: permissioneditor`. |
+| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | `type: password` (if G1 lands). |
+
+---
+
+## Spacing Scale
+
+Declared values for new layout in this phase (multiples of 4):
+
+| Token | Value | Tailwind | Usage |
+|-------|-------|----------|-------|
+| xs | 4px | `gap-1`, `p-1` | Gap between segments of the permission radio control and its track padding; gap between a callout title and its text |
+| sm | 8px | `gap-2`, `p-2` | Gap between the first-cell text and row-state badges; gap between badges; bulk menu inner padding; gap between a lock icon and its text |
+| md | 16px | `gap-4`, `px-4` | Permission row horizontal padding and label-to-control gap; segment horizontal padding |
+| lg | 24px | `gap-6` | Form grid column gap on the preview screen (inherited from FormGrid) |
+| xl | 32px | `px-8` | Preview footer horizontal padding (inherited from the form footer) |
+| 2xl | 48px | — | Not used by new components |
+| 3xl | 64px | — | Not used by new components |
+
+Fixed component dimensions (all multiples of 4 unless listed under exceptions): bulk menu width 240, maximum 320; menu item height 40; row-state badge height 24; permission row minimum height 56; permission section header height 44 (`h-row-head` token); in-field password toggle button 32×32; lock icon 14.
+
+Exceptions (inherited from Direction C and existing components, binding, not new):
+- **10px** (`gap-2.5`): gap between buttons in the list toolbar cluster and in the preview footer; menu item icon-to-text gap.
+- **12px** (`px-3`, `py-3`): bulk menu item horizontal padding (copies `UserMenu.vue` items); permission row vertical padding. No other new 12px spacing.
+- **14px** (`px-3.5`, `py-3.5`): control horizontal padding (password input, read-only preview box); callout and forbidden-banner vertical padding (copies `FormErrorBanner`).
+- **18px** (`px-[18px]`): callout and forbidden-banner horizontal padding (copies `FormErrorBanner`).
+- **20px** (`gap-5`): vertical gap between page blocks (header, status hint, card).
+- **6px** (`gap-1.5`): label-to-control gap of `FormField`, reused for the preview's `dt`-to-`dd` gap.
+- **34px** (`h-pager` token): height of one permission radio segment (same as a form tab).
+- **18px** checkbox box and **`rounded-[14px]`** menu radius: copied from `DataTable.vue` and `UserMenu.vue`.
+- **22px** form-grid row gap (`gap-y-[22px]`): inherited by the preview grid.
+
+---
+
+## Typography
+
+New surfaces use exactly **3 sizes** and **2 weights**. All inherit DM Sans and the 14px / 1.5 base.
+
+| Role | Size | Weight | Line Height | Used by |
+|------|------|--------|-------------|---------|
+| Body | 14px | 400 | 1.5 | Bulk menu items, preview values, permission labels, callout text, password input |
+| Label | 14px | 600 | 1.5 | Button text (existing `Button`), preview field labels (`dt`), permission section headers, selected radio segment, callout title |
+| Meta | 13px | 400 | 1.5 | Permission comments, the locked-items note, field comments, unselected radio segment text |
+| Badge | 12px | 600 | 1.5 | Row-state badges and the "Allow" column heading in checkbox mode (the size and weight of the existing list pills and table headers) |
+
+Weights used by new surfaces: **400** and **600** only. Inherited chrome keeps its own style and is not varied: the preview page title copies the form title (`text-[24px] font-bold tracking-[-0.02em]`), and the confirmation title is the existing `ConfirmDialog` title (`text-[20px] font-bold`). No new surface introduces 500, 700, 17px or 26px text. Every count in a toast or confirm uses the existing plural forms; no new numeric display needs `tabular-nums`.
+
+---
+
+## Color
+
+All colours are existing CSS variables; light / dark values from `main.css`. Hard-coded hex values are forbidden in new components.
+
+| Role | Value (light / dark) | Usage |
+|------|----------------------|-------|
+| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; list card, preview card, bulk menu, permission editor rows |
+| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6 | Read-only preview boxes, permission section headers and radio track, the "disabled" badge, the "deleted" badge border, muted row text, comments |
+| Accent (10%) | `primary` #22304d / #fcd34d; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45); `sel` #fdf3cf / #3a3622 | See the reserved list below |
+| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | The "negative" badge and first-cell text, the selected "Deny" segment, danger callouts, the forbidden banner, invalid borders, the existing delete buttons and delete confirm |
+| Positive | `ok-bg` #e3f4e8 / #173826, `ok-text` #1c6b35 / #8fdfa8 | The selected "Allow" segment and the "Yes" pill in preview (the existing "Tak" badge colours) |
+
+Accent reserved for (exhaustive for this phase):
+1. `primary` fill: the single primary button of each screen (the preview footer's edit button; the existing create and Save buttons), a checked permission checkbox, and the confirm button of a non-destructive confirmation (existing `ConfirmDialog`).
+2. `ring` (3px): focus-visible on every new focusable element. Never removed.
+3. `sel` tint: the warning callout background, and the existing selected-row and selection-pill backgrounds (unchanged).
+
+Accent is never used for: bulk menu items, record action buttons (outline), row states, the "Inherit" segment, lock icons, or links in hints.
+
+Destructive colour rule for actions: only the built-in `delete` (list toolbar, form footer) uses the `danger` button and the danger confirm. Declared bulk and record actions (activate, deactivate, restore, ban, unban, unsuspend) are reversible, so their menu items and buttons are neutral and their confirm button is `primary`.
+
+### Partial style kit addition (framework, `admin/src/styles/main.css` `@layer components`)
+
+Stable class names a preview status hint (a server partial) may use through the allowlisted `class` attribute. They read only public `--c-*` variables, so dark mode swaps with no extra rule.
+
+| Class | Rules |
+|-------|-------|
+| `.summer-callout` | `display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 14px 18px; border-radius: 12px; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere`. No border. |
+| `.summer-callout--warning` | `background: var(--c-sel); color: var(--c-text)` |
+| `.summer-callout--danger` | `background: var(--c-danger-soft); color: var(--c-danger)` |
+| `.summer-callout__title` | `margin: 0; font-weight: 600` |
+| `.summer-callout__text` | `margin: 0; font-weight: 400` |
+
+Recommended markup (framework docs and the neutral fixture). `role="status"` is on the allowlist and makes a hint that appears after an action polite to screen readers:
+
+```html
+
+
{{ trans "…" }}
+
{{ trans "…" }}
+
+```
+
+Callouts carry no icon (partials cannot render SVG) and no link or button: the action a hint refers to is a record action button in the footer.
+
+---
+
+## Surface Contracts
+
+### S1. Bulk actions menu (D-09)
+
+- **When shown:** the list schema's `bulkActions` contains at least one declared action after the server's permission filter. An action the admin may not run is **not rendered** (never shown disabled). With zero permitted actions there is no menu. The built-in `delete` is not in the menu: it stays the existing toolbar button.
+- **Placement:** in the `ListToolbar` right cluster (`gap-2.5`), directly after the selection pill and before the `toolbar.buttons` (Delete, then custom toolbar actions in declared order).
+- **Trigger:** `DropdownMenuTrigger as-child` around `Button variant="outline" size="md"`, label `backend::lang.list.bulk_actions`, with a trailing `ChevronDown` 16px `text-muted`. `data-action="bulk-actions"`. Disabled while nothing is selected or an action is running. A disabled trigger keeps its label; it does not hide.
+- **Menu:** `DropdownMenuRoot :modal="false"`, `DropdownMenuContent align="end" :side-offset="8"` with `z-50 flex w-[240px] max-w-[320px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu` (the `UserMenu` surface). Items are in declared order, with no icons, groups or separators.
+- **Item:** `DropdownMenuItem`, `flex min-h-10 cursor-pointer items-center rounded-control px-3 py-2 outline-none transition-colors duration-150 ease-out data-[highlighted]:bg-hover`, 14px/400, `data-bulk-action="{name}"`, label = the action's localized `Label`. A long label wraps inside the 240–320px menu; it is never truncated.
+- **Confirm (always):** choosing an item opens `ConfirmDialog` (not danger). The message is the action's `Confirm` text; when the action declares none, `backend::lang.list.bulk_confirm` with `:action` = the label and `:count` = the selected count. The confirm button label is the action label. The request runs through `confirm.ask(request, run)`: the dialog stays open, both buttons disabled, with the spinner on the confirm button, until the POST settles.
+- **Request:** the ids of the current selection (selection is always the current page: any query change clears it).
+- **Success (200):** success toast with the server `message`; when the server sends none, `backend::lang.list.bulk_done` with the affected count. Then clear the selection, reload the list (new row states show), and bump the header-partial reload key.
+- **Failure:**
+
+| Status | Presentation |
+|--------|--------------|
+| 409 (partial selection) | Danger toast `backend::lang.list.bulk_stale`; clear the selection and reload the list |
+| 403 | Danger toast: server `error.message`, else `backend::lang.list.action_forbidden`; selection kept |
+| 422 / other | Danger toast: server `error.message`, else `backend::lang.extension.action_failed`; selection kept; nothing reloaded |
+
+- **Focus:** after the dialog closes (confirm or cancel), focus returns to the menu trigger. Esc in the menu closes it and returns focus to the trigger (Reka default).
+- **Delete button (unchanged visuals):** when a list sets `messages.deleteConfirm`, the confirm shows that text. Lists whose delete is permanent must say so there (see Plugin screens).
+
+### S2. Record actions (D-10)
+
+- **Where:** in the preview screen's footer (S3). v0.1.3 renders record actions on the preview screen only. A form that declares `recordActions` without a preview has nowhere to show them; the recommended server rule is a boot error (planner's decision, not a UI state).
+- **Which:** the record response lists the actions that (a) the admin may run and (b) apply to the record's current state. Anything else is **not rendered**.
+- **Style:** `Button variant="outline" size="md"`, text only, label = localized `Label`, `data-record-action="{name}"`, in declared order.
+- **Confirm (always):** `ConfirmDialog` (not danger), message = the action's `Confirm`, else `backend::lang.form.action_confirm` with `:action`. Confirm button label = the action label. Busy handling as in S1.
+- **Success:** success toast with the server `message`, else `backend::lang.form.action_done`. Then reload the record (values, labels, offered actions) and refetch the status hint. The previous content stays visible during the reload; no skeleton flash.
+- **Failure:**
+
+| Status | Presentation |
+|--------|--------------|
+| 409 (no longer applies) | Danger toast `backend::lang.form.action_stale`; reload the record and the hint |
+| 404 | The existing form load-failure alert (`backend::lang.form.load_failed`) replaces the card; the footer keeps only the back path |
+| 403 | Danger toast: server message, else `backend::lang.list.action_forbidden` |
+| other | Danger toast: server message, else `backend::lang.extension.action_failed` |
+
+- While one action runs, every footer button is disabled.
+
+### S3. Preview screen (D-11)
+
+- **Route:** `/:vendor/:plugin/:controller/:id(\d+)/preview`, name `preview`, inside the shell. `CONTROLLER_ROUTES` gains `preview`. `mapWinterUrl` maps `…/preview/:id` to it. Opening the route for a form with no preview replaces it with the record (update) route.
+- **Entry:** a list whose `recordUrl` points at preview opens it on row click and on the first-cell link. `create.redirect` and `update.redirectClose` may point at it.
+- **Layout:** the `FormView` page frame, unchanged in geometry: `section.flex.w-full.flex-col.gap-5.pb-24`.
+ 1. **Header:** the 40px outline back button (`ArrowLeft`, aria-label `backend::lang.form.return_to_list`, goes to the list); the title (record name: the first text field's value, 24/700 inherited) with the subtitle `messages.form.preview`; `FormTabs` when the visible fields declare tabs.
+ 2. **Status hint slot:** when the form's preview config names a header partial, `PartialHost variant="header"` renders it here, between the header and the card, outside every tab so it is always visible. It is refetched after every record action. Zero nodes: the slot renders nothing and takes no gap. Loading: one `bg-skel` block, 68px tall, `rounded-inner`, `aria-hidden`. Failure: the existing extension failure box with `backend::lang.extension.partial_failed`; the rest of the screen works.
+ 3. **Card:** `rounded-card border border-border bg-surface p-7 shadow-card`, holding the field grid as a `
` with the `FormGrid` column and span rules.
+ 4. **Footer** (the form footer's geometry: `fixed inset-x-0 bottom-0 z-30 flex flex-wrap items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5`): nothing on the left; on the right (`ml-auto flex flex-wrap items-center gap-2.5`) the record actions (S2), then the one primary button: `Button variant="primary" :icon="Pencil"`, label `messages.form.edit`, linking to the update route, `data-action="edit"`. There is no delete and no save on preview.
+- **Focal point:** the record title first, then the status hint when present (the only tinted block on the page), then the primary edit button at the bottom right, the only `primary` fill.
+- **Fields shown:** every field whose `context` allows `preview` (a field with no `context` shows everywhere; `context: preview` shows only here).
+- **Field rendering (`PreviewField`):** each field is `
` with `
` (label, no required asterisk) and `
`. Values are text, never disabled inputs, so they keep full contrast and are read in order.
+
+| Field type | `dd` content |
+|------------|--------------|
+| `text`, `number`, `dropdown` | The read-only box `flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5`, value as text (dropdown: the option label), `overflow-wrap: anywhere` |
+| `textarea` | The same box with `items-start py-3 whitespace-pre-wrap`; it grows with the text |
+| `checkbox`, `switch` | The list pills from `CellValue.vue`: "Yes" (`bg-ok-bg text-ok-text`) or "No" (outline, muted), using `backend::lang.list.column_switch_true` / `_false` |
+| `datepicker` | Its existing read-only rendering (12.2) |
+| `relation` (single) | The read-only box with the label, or the muted `emptyOption` / "—" |
+| `relation` (multiple) | The box with `flex-wrap gap-1.5 p-1.5` holding the existing chips without remove buttons; none: muted "—" |
+| `fileupload` | Its existing read-only mode (12.2) |
+| `partial` | Rendered as on a form |
+| `permissioneditor` | Rendered with every control disabled (S5 read-only) |
+| `password`, `widget`, `relation-manager` | Not rendered on preview |
+
+ An empty value shows a muted "—" (`backend::lang.list.empty_value`).
+- **Loading:** as `FormView`: the title is blank and the card is not rendered until the schema and record arrive; the footer buttons are disabled.
+- **Load failure / 404:** the existing alert block with `backend::lang.form.load_failed`; the back button still works.
+- **Update form when a preview exists:** the back arrow and Cancel go to the preview route, not the list (aria-label `backend::lang.form.return_to_preview`); the unsaved-changes confirm applies as today. After a delete, the form goes to the list.
+
+### S4. Row state (D-12)
+
+- **States:** a fixed framework set: `deleted`, `negative`, `disabled`. A row may carry several. Any other value from the server is ignored by the SPA.
+- **Announced in text: yes.** Colour and strike-through alone fail WCAG 1.4.1, so every state also renders a visible text badge in the row's first cell. Screen readers read it as part of the cell; no `aria-label` tricks.
+- **Badge labels:** list messages, overridable per list in `config_list.yaml` `messages` (`rowStateDeleted`, `rowStateNegative`, `rowStateDisabled`), defaulting to `backend::lang.messages.list.row_state_*`.
+- **Row and badge styling** (the row background is never changed, so the existing selected `bg-sel` and `hover:bg-hover` rules stay as they are):
+
+| State | Row text | Badge |
+|-------|----------|-------|
+| `deleted` | First-cell text `line-through`; every cell `text-muted` | `border border-border-strong text-muted` (the existing "No" pill) |
+| `negative` | First-cell text `text-danger`; other cells unchanged | `bg-danger-soft text-danger` |
+| `disabled` | Every cell `text-muted`; first cell keeps weight 600 | `bg-subtle text-muted` |
+
+- **Badge geometry:** `inline-flex h-6 shrink-0 items-center rounded-pill px-2.5 text-[12px] font-semibold whitespace-nowrap`, `data-row-state="{state}"`. Badges are never struck through.
+- **First cell with states:** `flex items-center gap-2`: the link or text (`min-w-0 truncate`), then the badges in the fixed order `deleted`, `negative`, `disabled` (`flex gap-2`). When states combine, the text styles combine (`deleted` + `negative`: struck through and `text-danger`).
+- **Row attribute:** `
` for tests and plugin CSS. No free CSS class from the server is ever applied.
+- Row click, selection, sorting and links are unchanged for every state. A `deleted` row is still openable.
+
+### S5. `permissioneditor` field (D-16)
+
+- **Registry:** `permissioneditor` joins `renderers`, and `groupLabelledTypes` (the visible label is a `` the control's `role="group"` points at with `aria-labelledby`). It is a value field: its value is part of the save body.
+- **"Tabbed" means grouped.** Permissions are grouped by their `tab` into **sections** of one list, as the PHP editor renders them. The control does not nest a second tablist inside the form's tabs. Sections keep the option order the server sends; a permission with no `tab` goes to a last section labelled `backend::lang.permissioneditor.other`.
+- **Container:** `overflow-hidden rounded-inner border border-border` (`border-danger` when invalid).
+- **Section header:** `flex h-row-head items-center justify-between gap-4 bg-subtle px-4`, the tab label 14/600. In checkbox mode the right side shows the column heading `backend::lang.permissioneditor.allow` at 12/600 `text-muted`. Rendered as an `
` inside a `role="group"` named by it.
+- **Permission row:** `flex min-h-[56px] items-center justify-between gap-4 border-t border-border px-4 py-3`. Left (`min-w-0 flex-col`): the label 14/400 with `id`, then the comment 13/400 `text-muted` when present. Right: the control, `shrink-0`. Below 640px the row wraps and the control sits under the label.
+- **Radio mode** (`mode: radio`): one `RadioGroupRoot orientation="horizontal"` per row, `aria-labelledby` the row label. Styled as a segmented control: track `inline-flex gap-1 rounded-inner border border-border bg-subtle p-1`; each `RadioGroupItem` is `inline-flex h-pager items-center rounded-tab px-4 text-[13px] text-muted transition-colors duration-150 ease-out hover:text-text`. Segments in fixed order with visible text:
+
+| Segment | Value | Label key | Selected style |
+|---------|-------|-----------|----------------|
+| Allow | `1` | `backend::lang.permissioneditor.allow` | `bg-ok-bg text-ok-text text-[14px] font-semibold` |
+| Inherit | `0` | `backend::lang.permissioneditor.inherit` | `bg-surface text-text shadow-tab text-[14px] font-semibold` |
+| Deny | `-1` | `backend::lang.permissioneditor.deny` | `bg-danger-soft text-danger text-[14px] font-semibold` |
+
+ A code missing from the value is "Inherit". The state is carried by the selected segment's text, not by colour alone.
+- **Checkbox mode** (`mode: checkbox`): one `CheckboxRoot` per row, `aria-labelledby` the row label; the box is the list checkbox (`flex size-[18px] items-center justify-center rounded-checkbox border`, unchecked `border-border-strong bg-surface`, checked `border-primary bg-primary text-on-primary` with `Check` 14px). The row label is a `