Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md
2026-10-04 16:07:57 +02:00

58 KiB
Raw Blame History

phase, slug, status, shadcn_initialized, preset, created
phase slug status shadcn_initialized preset created
12.1 user-plugin-admin-screens draft false none 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:

<div class="summer-callout summer-callout--warning" role="status">
  <p class="summer-callout__title">{{ trans "…" }}</p>
  <p class="summer-callout__text">{{ trans "…" }}</p>
</div>

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 <dl> 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 <div class="flex min-w-0 flex-col gap-1.5"> with <dt class="font-semibold"> (label, no required asterisk) and <dd class="m-0">. 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: <tr data-row-states="deleted negative"> 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 <span> 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 <h3> 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 <label> for it, so clicking the text toggles.
  • Value emitted (default; the wire shape is the planner's to confirm): an object of code → integer. Radio: 1 or -1, an inherited code omitted. Checkbox: 1 for a checked code, an unchecked code omitted. Codes not in the option list are never sent.
  • Locked option (an option the server marks locked): its control is disabled and aria-disabled, the row label is followed by Lock 14px text-muted (aria-hidden), and the comment line is replaced by backend::lang.permissioneditor.locked (13px muted, visible text). The stored value still shows. A locked row is skipped by Tab.
  • Read-only (preview, or attributes: readonly: true): every control disabled, no lock icons, no locked text.
  • Keyboard: Tab moves row to row; inside a radio row the arrow keys move and select (Reka). Space toggles a checkbox.
  • Invalid: a 422 on the field renders through the FormField error line and the container border becomes danger.
  • Empty: the server supplies no options: the read-only box (flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5 text-muted) with backend::lang.permissioneditor.empty.
  • Dirty: any change marks the form dirty and clears the field's error, like any value field.
  • No "allow all" control, no search and no inner scroll: the page scrolls.

S6. Locked relation options and the forbidden presentation (D-07; needs G4)

Locked options in RelationField:

  • Multiple mode, selected and locked: the chip keeps its look (bg-subtle, initials, label 14/600) but has no remove button. In its place sits a 24px slot holding Lock 14px text-muted (aria-hidden), and the chip carries visually hidden text backend::lang.form.locked_item (:name). Backspace in the empty search input skips locked chips: it removes the last unlocked chip, or does nothing.
  • Listbox, locked and not selected: the option is aria-disabled="true", text-muted cursor-not-allowed, with Lock 14px at the right edge; no hover highlight, not chosen by click or Enter, and skipped by arrow-key movement.
  • Single mode: locked options in the listbox behave the same. When the current value itself is locked, the field renders the existing read-only box with a trailing Lock 14px.
  • Note: when the field has at least one locked option (selected or seen in the listbox), one line shows under the control: flex items-center gap-2 text-[13px] text-muted, Lock 14px, text backend::lang.form.locked_note. Its id is added to the control's aria-describedby.
  • The lock is a display aid only; the server enforces the rule.

Forbidden save (403 on create or update):

  • Nothing is saved and the form keeps every value the admin entered; it stays dirty.
  • A banner renders in the FormErrorBanner slot with its geometry (flex items-start gap-3 rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger, role="alert", CircleAlert 20px, data-forbidden-banner): the server error.message, else backend::lang.form.forbidden. It is not a toast, because a refused save must stay readable.
  • When error.details names fields, each message also renders on its field through the normal error line, and the first such field is focused (tab switched when needed), as for a 422.
  • The banner clears on the next save attempt.
  • Forbidden delete, bulk action or record action: a danger toast with the server message, else backend::lang.list.action_forbidden.

S7. password field (D-19; needs G1/G2) and preset (needs G7)

type: password:

  • A wrapper relative; <input type="password"> with controlClass(invalid) plus h-input pr-12, autocomplete="new-password", spellcheck="false". The label, comment and error line come from FormField as for a text field.
  • A show/hide toggle sits inside the right edge (absolute top-1/2 right-1.5 -translate-y-1/2): a 32×32 rounded-pager text-muted hover:bg-hover hover:text-text button with Eye / EyeOff 16px, aria-pressed, aria-label backend::lang.form.show_password / hide_password. It switches the input between password and text. It is reachable by Tab after the input.
  • The field is always empty on load: the server never sends a value. An empty field on update means "unchanged" and is not sent. A non-empty value marks the form dirty.
  • After a successful save, every password field is cleared and returns to hidden.
  • A confirmation is a second, ordinary password field; a mismatch is the server's 422 on the field. The SPA does not compare the two.
  • Never rendered on preview. Never written to a toast, a log or the URL.

preset (text fields, create only): while the target field is untouched, it follows the source field's value on each input (type slug: lower-case ASCII, runs of other characters become one -, trimmed). The first manual edit of the target stops it for the rest of the session. There is no visual indicator, and the target stays an ordinary editable input. On update nothing is filled.


Copywriting Contract

All framework copy is keyed in modules/phrasebook/backend/lang/{en,pl}/lang.yaml. Overridable list and form copy uses cabana's existing messages blocks (config_list.yaml, config_form.yaml), defaulting to backend::lang.messages.*. Plural texts are CLDR maps (one, few, many, other for Polish). Placeholders use :name syntax. No literal UI string appears in admin/src.

Framework: contract elements

Element Copy (EN / PL)
Primary CTA (preview footer) messages.form.edit "Edit record" / "Edytuj rekord" (overridable per form)
Bulk menu trigger list.bulk_actions "Bulk actions" / "Działania masowe"
Empty state (list) Unchanged: messages.list.empty → noRecordsMessage → list.no_records "There are no records in this view." / "Brak rekordów w tym widoku." Plugins set their own (see Plugin screens).
Empty state (permission editor) permissioneditor.empty "No permissions are defined yet." / "Nie zdefiniowano jeszcze żadnych uprawnień."
Empty state (preview value) existing list.empty_value "—"
Error state (forbidden save) form.forbidden "You do not have permission to make this change. Nothing was saved." / "Nie masz uprawnień do wprowadzenia tej zmiany. Nic nie zostało zapisane."
Error state (forbidden action) list.action_forbidden "You do not have permission to run this action." / "Nie masz uprawnień do wykonania tej akcji."
Error state (stale bulk selection) list.bulk_stale "Some of the selected records are no longer available. The list has been refreshed; select the records again." / "Część zaznaczonych rekordów nie jest już dostępna. Lista została odświeżona; zaznacz rekordy ponownie."
Error state (record action no longer applies) form.action_stale "This action no longer applies to this record. The page has been refreshed." / "Ta akcja nie dotyczy już tego rekordu. Strona została odświeżona."
Error state (action failed, other) existing extension.action_failed "The action could not be completed. Please try again." / "Nie udało się wykonać akcji. Spróbuj ponownie."
Error state (preview load, status hint) existing form.load_failed; existing extension.partial_failed

Framework: destructive and confirmed actions

Action Confirmation approach Copy (EN / PL)
Declared bulk action ConfirmDialog, not danger, always The action's Confirm; default list.bulk_confirm "Run “:action” on the selected (:count)?" / "Wykonać „:action” na zaznaczonych (:count)?". Confirm button: the action label.
Record action ConfirmDialog, not danger, always The action's Confirm; default form.action_confirm "Run “:action” on this record?" / "Wykonać „:action” na tym rekordzie?". Confirm button: the action label.
Bulk delete (existing) ConfirmDialog, danger (unchanged) messages.list.delete_confirm (overridable); confirm button form.delete
Form delete (existing) ConfirmDialog, danger (unchanged) messages.form.delete_confirm (overridable)
Leaving a dirty form to preview Existing unsaved-changes ConfirmDialog (unchanged) form.unsaved_confirm, form.discard

D-13: no typed confirmation anywhere. A permanent delete is made clear by its confirm text.

Framework: success feedback (toasts, role="status")

Event Copy (EN / PL)
Bulk action done, no server message list.bulk_done one: "Action completed for :count record." other: "Action completed for :count records." / one: "Wykonano akcję dla :count rekordu." few/many/other: "Wykonano akcję dla :count rekordów."
Record action done, no server message form.action_done "Action completed." / "Akcja została wykonana."
Bulk or record action done, with server message the server message, verbatim

Framework: all new keys (complete list for the planner)

backend::lang.list.*: bulk_actions; bulk_confirm; bulk_done; bulk_stale; action_forbidden.

backend::lang.form.*: return_to_preview "Back to preview" / "Wróć do podglądu"; action_confirm; action_done; action_stale; forbidden; locked_item "Locked: :name" / "Zablokowane: :name"; locked_note "Items marked with a lock need an additional permission to change." / "Zmiana pozycji oznaczonych kłódką wymaga dodatkowego uprawnienia."; show_password "Show password" / "Pokaż hasło"; hide_password "Hide password" / "Ukryj hasło".

backend::lang.permissioneditor.*: allow "Allow" / "Zezwól"; inherit "Inherit" / "Dziedzicz"; deny "Deny" / "Odmów"; locked "You cannot change this permission." / "Nie możesz zmienić tego uprawnienia."; empty; other "Other" / "Inne".

backend::lang.messages.list.* (new defaults; new keys on cabana's list messages, overridable per list; YAML spellings rowStateDeleted, rowStateNegative, rowStateDisabled): row_state_deleted "Deleted" / "Usunięty"; row_state_negative "Blocked" / "Zablokowany"; row_state_disabled "Inactive" / "Nieaktywny".

backend::lang.messages.form.* (new defaults; new keys on cabana's form messages, overridable per form; YAML spellings preview, edit): preview "Record preview" / "Podgląd rekordu"; edit "Edit record" / "Edytuj rekord".

The single-word segment labels (Allow / Inherit / Deny) and badge labels are state names, not CTAs.


Plugin screens (golem15.user, repo sm-user-plugin)

Copy lives in the plugin's lang/{en,pl}/lang.yaml under the PHP key names (golem15.user::lang.*), with the PHP values unless marked new or changed. These screens are YAML-driven and add no component.

Navigation and permissions

Main item golem15.user::lang.users.menu_label "Users" / "Użytkownicy" with side items "Users" / "Użytkownicy", groups.all_groups "User Groups" / "Grupy użytkowników", organisation.menu_label "Organisations" / "Organizacje". An item the admin may not open is not rendered (existing rule). The extra permission (D-04, discretion): code golem15.users.manage_privileged_groups, tab plugin.tab, label new plugin.manage_privileged_groups "Manage privileged group membership" / "Zarządzaj członkostwem w grupach uprzywilejowanych".

Users list

Element Copy (EN / PL)
Primary CTA (heading button) users.new_user "New User" / "Nowy użytkownik"
Columns Name, Email, Registered (datetime), Last seen (datetime); labels from user.*
Filters Group (scope select), Registered (date range), Activated (switch): the existing FilterBar controls
Empty state new users.list_empty "No users match this view. Clear the filters, or add one with New User." / "Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik."
Row-state badges deleted → new users.state_deactivated "Deactivated" / "Dezaktywowany"; negative → new users.state_banned "Banned" / "Zbanowany"; disabled → new users.state_not_activated "Not activated" / "Nieaktywowany"
Delete confirm (permanent, D-13) users.delete_selected_confirm "Delete the selected users? These users will be permanently removed and unrecoverable." / changed "Usunąć wybranych użytkowników? Zostaną trwale usunięci bez możliwości odzyskania."

Bulk actions, in menu order (D-14; delete is the toolbar button):

Action Label Confirm Success message
activate "Activate selected" / "Aktywuj wybranych" "Activate the selected users?" / "Aktywować wybranych użytkowników?" "Successfully activated the selected users." / "Pomyślnie aktywowano wybranych użytkowników."
deactivate "Deactivate selected" / "Dezaktywuj wybranych" "Deactivate the selected users?" / "Dezaktywować wybranych użytkowników?" "Successfully deactivated the selected users." / "Pomyślnie dezaktywowano wybranych użytkowników."
restore "Restore selected" / changed "Przywróć wybranych" "Restore the selected users?" / "Przywrócić wybranych użytkowników?" "Successfully restored the selected users." / "Pomyślnie przywrócono wybranych użytkowników."
ban "Ban selected" / "Zbanuj wybranych" "Ban the selected users?" / "Zbanować wybranych użytkowników?" "Successfully banned the selected users." / changed "Pomyślnie zbanowano wybranych użytkowników."
unban "Unban selected" / "Odbanuj wybranych" "Unban the selected users?" / "Odbanować wybranych użytkowników?" "Successfully unbanned the selected users." / changed "Pomyślnie odbanowano wybranych użytkowników."

Keys: users.{action}_selected, users.{action}_selected_confirm, users.{action}_selected_success.

Users preview

  • Row click opens preview; the preview's edit button reads users.update_details "Update details" / "Zaktualizuj szczegóły" (the form's messages.edit).
  • Preview-only fields: Created IP Address, Last IP Address. Not shown: password fields, send_invite, the Permissions editor (context: update).
  • No scoreboard in this phase (discretion: optional, left out; Registered and Last seen are already list columns).
  • Status hint: one callout, by precedence banned → deactivated → not activated → suspended. No hint when none applies.
State Callout Title (EN / PL) Text (EN / PL)
Banned danger users.banned_hint_title "User has been banned" / "Użytkownik został zbanowany" users.banned_hint_desc "This user has been banned by an administrator and will be unable to sign in." / "Ten użytkownik został zbanowany przez administratora i nie będzie mógł się zalogować."
Deactivated (trashed) danger users.trashed_hint_title "User has deactivated their account" / "Użytkownik dezaktywował swoje konto" users.trashed_hint_desc (PHP value, unchanged)
Not activated warning users.activate_warning_title "User not activated!" / changed "Użytkownik nieaktywowany!" users.activate_warning_desc "This user has not been activated and may be unable to sign in." / "Ten użytkownik nie został aktywowany i może nie być w stanie się zalogować."
Suspended warning new users.suspended_hint_title "User is temporarily suspended" / "Użytkownik jest tymczasowo zawieszony" new users.suspended_hint_desc "Too many failed sign-in attempts. The suspension ends on its own, or you can lift it now with Unsuspend user." / "Zbyt wiele nieudanych prób logowania. Blokada wygaśnie sama; możesz też zdjąć ją teraz przyciskiem Odblokuj użytkownika."

Record actions (footer buttons, shown only when they apply):

Action Applies when Label Confirm Success message
activate not activated new users.activate_user "Activate user" / "Aktywuj użytkownika" users.activate_confirm "Do you really want to activate this user?" / "Czy na pewno chcesz aktywować tego użytkownika?" users.activated_success "User has been activated" / "Użytkownik został aktywowany"
unban banned users.unban_user "Unban this user" / "Odbanuj tego użytkownika" users.unban_confirm "Do you really want to unban this user?" / "Czy na pewno chcesz odbanować tego użytkownika?" users.unbanned_success "User has been unbanned" / "Użytkownik został odbanowany"
unsuspend suspended new users.unsuspend_user "Unsuspend user" / "Odblokuj użytkownika" users.unsuspend_confirm "Unsuspend this user?" / changed "Odblokować tego użytkownika?" users.unsuspend_success "User has been unsuspended." / "Udało się odblokować użytkownika."

Users form

  • Tabs: user.account "Account" / "Konto" (name, surname, email, send_invite on create, password and confirmation, groups, organisation, avatar) and "Permissions" / "Uprawnienia" (new user.permissions_tab; the permissioneditor in radio mode, update only). Name and surname move into the Account tab (the Go dialect has no outside fields); the avatar moves there too (no secondary-tab sidebar).
  • Password label: user.create_password on create, user.reset_password on update; one YAML field needs one label, so use new user.password "Password" / "Hasło" with new comment user.password_comment "On a new user, enter the sign-in password. On an existing user, leave empty to keep the current password." / "Dla nowego użytkownika wpisz hasło do logowania. Dla istniejącego pozostaw puste, aby nie zmieniać hasła." Confirmation: user.confirm_password and its comment.
  • send_invite: the existing checkbox toggle card, on by default, create only; label and comment from user.send_invite*.
  • Groups: the relation field in multiple mode; privileged groups are locked (S6) for an admin without the extra permission.
  • Forbidden save (D-07): server message new users.privileged_group_forbidden "You do not have permission to change membership of privileged groups. Nothing was saved." / "Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane.", with details on groups so the field is marked.
  • Delete confirm (permanent): users.delete_confirm "Do you really want to delete this user? This user will be permanently removed and unrecoverable." / changed "Czy na pewno chcesz usunąć tego użytkownika? Zostanie trwale usunięty bez możliwości odzyskania."
  • After create: go to preview. Save and close: go to preview. Cancel and back: go to preview.

User Groups

Element Copy (EN / PL)
Primary CTA groups.new_group "New Group" / "Nowa grupa"
Columns Name, Code, Created, Users (users_count, not sortable)
Empty state new groups.list_empty "No user groups yet. Create the first one with New Group." / "Brak grup użytkowników. Utwórz pierwszą przyciskiem Nowa grupa."
Form Name (left), Code (right, preset from name, comment group.code_comment), Description (textarea), Permissions (permissioneditor, checkbox mode)
Delete confirm groups.delete_confirm "Do you really want to delete this group?" / "Czy na pewno chcesz usunąć tę grupę?"
Forbidden (D-06) new groups.privileged_forbidden "You do not have permission to create, re-code or delete a privileged group. Nothing was saved." / "Nie masz uprawnień do tworzenia, zmiany kodu ani usuwania grup uprzywilejowanych. Nic nie zostało zapisane." (banner on save, toast on delete)

No checkboxes, no bulk actions, no preview: rows open the update form.

Organisations

Element Copy (EN / PL)
Primary CTA organisation.new "New Organisation" / "Nowa organizacja"
Columns Name, Slug, Created
Empty state new organisation.list_empty "No organisations yet. Create the first one with New Organisation." / "Brak organizacji. Utwórz pierwszą przyciskiem Nowa organizacja."
Form Name (required, left), Slug (right, preset slug from name, comment organisation.slug_comment), Description (textarea), Avatar (fileupload, image, 120×120), Members tab (relation-manager, update only)
Members toolbar link → new organisation.add_member "Add member" / "Dodaj członka"; unlink → new organisation.remove_members "Remove selected" / "Usuń zaznaczonych"
Members unlink confirm new organisation.remove_members_confirm "Remove the selected (:count) from this organisation? The users are not deleted." / "Usunąć zaznaczonych (:count) z tej organizacji? Użytkownicy nie zostaną usunięci."
Members empty new organisation.members_empty "This organisation has no members yet. Add users with Add member." / "Ta organizacja nie ma jeszcze członków. Dodaj użytkowników przyciskiem Dodaj członka."

No preview: rows open the update form. The relation manager's visuals are the 12.2 contract, unchanged.


Interaction & Accessibility Rules

  • Focus: every new interactive element shows the 3px ring (inherited from main.css). Disabled and locked controls are not focusable.
  • State is never colour-only: row states have text badges, radio segments have text labels, locked items have an icon plus text (visible note, hidden per-chip text).
  • Keyboard: the bulk menu and radio groups use Reka's roving focus. Every action button is a native <button type="button">.
  • Motion: no new animation. Hover and selection use the 150ms ease-out colour change; the confirm dialog keeps its 200ms fade.
  • Dark mode: every new surface reads --c-* variables only.
  • Server text (action labels, confirms, messages, hint partials) is rendered as text or through the partial allowlist; never as HTML.

UI Considerations

Applicable state considerations resolved: 37 covered, 5 backstop, 2 dismissed, 0 unresolved.

Category Element(s) Status Resolution / Reason
empty S1 bulk menu ✅ covered With zero permitted bulk actions the menu is not rendered; with nothing selected the trigger is disabled and keeps its label
loading S1 bulk action in flight ✅ covered The confirm dialog stays open with both buttons disabled and a spinner on the confirm button until the POST settles; the trigger is disabled
error S1 bulk action ✅ covered 409: danger toast list.bulk_stale, selection cleared, list reloaded; 403: danger toast (server message or list.action_forbidden); other: danger toast (server message or extension.action_failed), selection kept
populated S1 bulk menu ✅ covered Items in declared order, 14/400, 40px minimum height, no icons or separators
zero-one-many S1 bulk action count ✅ covered The default confirm and the list.bulk_done toast use :count with CLDR plural forms; one selected row and many share one flow
overflow S1 bulk menu ✅ covered Many actions extend the menu downward; Reka keeps it inside the viewport with collision handling; the toolbar cluster wraps with the existing flex-wrap
long-text S1 menu item and confirm ✅ covered A long label wraps inside the 240–320px menu and is never truncated; the confirm message wraps inside the 440px dialog
partial S1 bulk result ✅ covered The whole batch succeeds or rolls back (one transaction); the toast shows the server's affected count, which may be lower than the selection when the plugin skips rows
error S1 focus return 🧪 backstop An SPA unit test asserts focus returns to the bulk menu trigger after the confirm dialog closes by confirm and by cancel
empty S2 record actions ✅ covered With no applicable action the footer shows only the edit button
loading S2 record action in flight ✅ covered Confirm dialog busy; every footer button disabled
error S2 record action ✅ covered 409: toast form.action_stale and reload; 404: the form load-failure alert; 403 and other: danger toast
populated S2 after success ✅ covered Toast, then the record and hint reload in place; previous content stays visible during the reload
overflow / long-text S2 footer buttons ✅ covered The footer and its right cluster use flex-wrap; buttons are whitespace-nowrap and wrap to a second row instead of shrinking
loading S3 preview ✅ covered As FormView: blank title, no card until schema and record arrive, footer buttons disabled
error S3 preview load / 404 ✅ covered The existing alert block with form.load_failed; the back button works
empty S3 preview values ✅ covered An empty value shows the muted "—"; an empty multiple relation shows "—"
populated S3 preview ✅ covered dl grid in the form card with read-only boxes per the PreviewField table
long-text S3 preview values ✅ covered Values wrap with overflow-wrap: anywhere; textarea values keep line breaks and the box grows; the title truncates as on the form
partial S3 preview with tabs ✅ covered Fields group into the same tabs as the form; a tab with no preview-visible field is not rendered
zero-one-many S3 multiple relation ✅ covered None: "—"; one or many: chips wrap inside the box
loading S3 status hint ✅ covered First fetch: one 68px bg-skel block; refetch keeps the previous hint visible
empty S3 status hint ✅ covered Zero nodes render nothing and take no gap
error S3 status hint ✅ covered The extension failure box with extension.partial_failed; the rest of the screen works
long-text S3 callout ✅ covered .summer-callout wraps with overflow-wrap: anywhere and grows; never truncated
error S3 route without preview 🧪 backstop An SPA unit test asserts mapWinterUrl maps preview/:id to the preview route and that opening the preview route of a form without preview replaces it with the record route
populated S4 row state ✅ covered Text badge per state in the first cell plus the row text style from the state table
zero-one-many S4 row states ✅ covered No state: the row is unchanged; one or several: badges in the fixed order and combined text styles
long-text / overflow S4 first cell ✅ covered The first-cell text truncates (min-w-0 truncate), badges never shrink (shrink-0 whitespace-nowrap); the table keeps its horizontal scroll
error S4 unknown state 🧪 backstop An SPA unit test asserts a state outside the fixed set renders no badge and no class, and that each known state renders its text badge
loading / empty / error S4 row state ➖ dismissed States arrive with the list rows; list loading, empty and error states belong to the unchanged list view
empty S5 permission editor ✅ covered No options: the read-only box with permissioneditor.empty
populated S5 permission editor ✅ covered Sections by tab; rows with label, comment and a radio segment group or a checkbox
error S5 permission editor ✅ covered A 422 renders on the FormField error line and the container border becomes danger
partial S5 locked options ✅ covered A locked row shows its stored value, a disabled control, the lock icon and the permissioneditor.locked text; other rows stay editable
overflow S5 many permissions ✅ covered No inner scroll; the page scrolls; section headers are not sticky
long-text S5 labels and comments ✅ covered Label and comment wrap in the left column (min-w-0); the control never shrinks; below 640px the control moves under the label
zero-one-many S5 sections ✅ covered One tab: one section with its header; permissions without a tab form a last "Other" section
loading S5 permission editor ➖ dismissed Options arrive with the form schema; there is no separate fetch to show
error S5 keyboard and value 🧪 backstop An SPA unit test asserts radio mode emits 1 / -1 and omits inherit, checkbox mode emits 1 and omits unchecked, a locked row cannot change, and codes outside the options are never sent
partial S6 locked chips ✅ covered Locked chips have no remove button and are skipped by Backspace; unlocked chips work as before; the note shows under the field
error S6 forbidden save ✅ covered Banner with the server message or form.forbidden; values kept; fields named in details are marked and focused
error S6 locked option behaviour 🧪 backstop An SPA unit test asserts a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace
empty / error / partial S7 password ✅ covered Always empty on load; empty on update is not sent; a 422 shows on the field; the field is cleared after a successful save
long-text S7 password ✅ covered The input scrolls horizontally like any text input; pr-12 keeps text clear of the toggle
empty Plugin lists ✅ covered Each list sets its own empty copy (users.list_empty, groups.list_empty, organisation.list_empty); the empty-search state is the existing one
zero-one-many Users hint ✅ covered At most one callout by precedence; none when no state applies

Registry Safety

Registry Blocks Used Safety Gate
shadcn official none (shadcn not used: Vue project) not applicable
third-party registries none not applicable. No npm package or version is added (RESEARCH Package Legitimacy Audit, 2026-10-04)

Sources

Source Decisions used
12.1-CONTEXT.md D-01, D-03, D-04, D-06, D-07, D-09, D-10, D-11, D-12, D-13, D-14, D-15, D-16, D-18, D-19, D-20, D-22, D-23, D-24, D-25, and the discretion list (row state set and text announcement, preview content, permission code and label)
12.1-RESEARCH.md PHP screen inventory, gaps G1, G2, G4, G6, G7, contract names table (bulkActions, recordActions, route shapes), Pattern 3 (permission filter), Open Questions 3 and 6, the SPA seam list
10.1-UI-SPEC.md, 12.2-UI-SPEC.md Format, tokens, partial style kit, "not rendered when not permitted" rule, toast and confirm patterns, failure box
Codebase scan (admin/src, phrasebook, cabana/messages.go) Every class string, existing keys reused, the messages override mechanism, partial allowlist (class, role)
PHP reference (plugins/golem15/user) Toolbar and hint partials, permission editor layout, lang/{en,pl} values
Researcher defaults (no user question asked) Bulk actions always in one menu; always-confirm for bulk and record actions; row-state text badges with overridable labels; status hint as a preview header partial with .summer-callout; permission editor as sections with a segmented radio; forbidden save as a banner; suspended hint copy; record actions on preview only; Polish copy corrections marked changed

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